From 7a5c9a821a8cb4b8b00ccccfd4e0e86d0bf70962 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Jun 2025 16:17:47 -0500 Subject: [PATCH 01/27] Fix dashboard logging being escaped before parser (#9054) --- esphome/components/api/client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 4edcc90f4a..20136ef7b8 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -46,12 +46,10 @@ async def async_run_logs(config: dict[str, Any], address: str) -> None: time_ = datetime.now() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") - if dashboard: - text = text.replace("\033", "\\033") for parsed_msg in parse_log_message( text, f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}]" ): - print(parsed_msg) + print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) stop = await async_run(cli, on_log, name=name) try: From 592446e4304062664911ec8daafa2b348c020355 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Jun 2025 22:27:10 -0500 Subject: [PATCH 02/27] Always perform select() when loop duration exceeds interval (#9058) --- esphome/core/application.cpp | 18 ++++++++++++------ esphome/core/application.h | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 75a7052c63..87e6f33e04 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -117,7 +117,9 @@ void Application::loop() { // Use the last component's end time instead of calling millis() again auto elapsed = last_op_end_time - this->last_loop_; if (elapsed >= this->loop_interval_ || HighFrequencyLoopRequester::is_high_frequency()) { - yield(); + // Even if we overran the loop interval, we still need to select() + // to know if any sockets have data ready + this->yield_with_select_(0); } else { uint32_t delay_time = this->loop_interval_ - elapsed; uint32_t next_schedule = this->scheduler.next_schedule_in().value_or(delay_time); @@ -126,7 +128,7 @@ void Application::loop() { next_schedule = std::max(next_schedule, delay_time / 2); delay_time = std::min(next_schedule, delay_time); - this->delay_with_select_(delay_time); + this->yield_with_select_(delay_time); } this->last_loop_ = last_op_end_time; @@ -215,7 +217,7 @@ void Application::teardown_components(uint32_t timeout_ms) { // Give some time for I/O operations if components are still pending if (!pending_components.empty()) { - this->delay_with_select_(1); + this->yield_with_select_(1); } // Update time for next iteration @@ -293,8 +295,6 @@ bool Application::is_socket_ready(int fd) const { // This function is thread-safe for reading the result of select() // However, it should only be called after select() has been executed in the main loop // The read_fds_ is only modified by select() in the main loop - if (HighFrequencyLoopRequester::is_high_frequency()) - return true; // fd sets via select are not updated in high frequency looping - so force true fallback behavior if (fd < 0 || fd >= FD_SETSIZE) return false; @@ -302,7 +302,9 @@ bool Application::is_socket_ready(int fd) const { } #endif -void Application::delay_with_select_(uint32_t delay_ms) { +void Application::yield_with_select_(uint32_t delay_ms) { + // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run + // since select() with 0 timeout only polls without yielding. #ifdef USE_SOCKET_SELECT_SUPPORT if (!this->socket_fds_.empty()) { // Update fd_set if socket list has changed @@ -340,6 +342,10 @@ void Application::delay_with_select_(uint32_t delay_ms) { ESP_LOGW(TAG, "select() failed with errno %d", errno); delay(delay_ms); } + // When delay_ms is 0, we need to yield since select(0) doesn't yield + if (delay_ms == 0) { + yield(); + } } else { // No sockets registered, use regular delay delay(delay_ms); diff --git a/esphome/core/application.h b/esphome/core/application.h index d95f45e757..6c09b25590 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -575,7 +575,7 @@ class Application { void feed_wdt_arch_(); /// Perform a delay while also monitoring socket file descriptors for readiness - void delay_with_select_(uint32_t delay_ms); + void yield_with_select_(uint32_t delay_ms); std::vector components_{}; std::vector looping_components_{}; From 535c495b334718cca45e721ce83b81e499cca74c Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 13 Jun 2025 03:39:32 +0200 Subject: [PATCH 03/27] [nextion] Remove upload flags reset from success path to prevent TFT corruption (#9064) --- .../nextion/nextion_upload_arduino.cpp | 21 +++++++++++-------- .../components/nextion/nextion_upload_idf.cpp | 20 ++++++++++-------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index fcd665917c..6652e70172 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -337,23 +337,26 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { bool Nextion::upload_end_(bool successful) { ESP_LOGD(TAG, "TFT upload done: %s", YESNO(successful)); - this->is_updating_ = false; - this->ignore_is_setup_ = false; - - uint32_t baud_rate = this->parent_->get_baud_rate(); - if (baud_rate != this->original_baud_rate_) { - ESP_LOGD(TAG, "Baud back: %" PRIu32 "->%" PRIu32, baud_rate, this->original_baud_rate_); - this->parent_->set_baud_rate(this->original_baud_rate_); - this->parent_->load_settings(); - } if (successful) { ESP_LOGD(TAG, "Restart"); delay(1500); // NOLINT App.safe_reboot(); + delay(1500); // NOLINT } else { ESP_LOGE(TAG, "TFT upload failed"); + + this->is_updating_ = false; + this->ignore_is_setup_ = false; + + uint32_t baud_rate = this->parent_->get_baud_rate(); + if (baud_rate != this->original_baud_rate_) { + ESP_LOGD(TAG, "Baud back: %" PRIu32 "->%" PRIu32, baud_rate, this->original_baud_rate_); + this->parent_->set_baud_rate(this->original_baud_rate_); + this->parent_->load_settings(); + } } + return successful; } diff --git a/esphome/components/nextion/nextion_upload_idf.cpp b/esphome/components/nextion/nextion_upload_idf.cpp index 8f54fbd8ac..fc98056bc3 100644 --- a/esphome/components/nextion/nextion_upload_idf.cpp +++ b/esphome/components/nextion/nextion_upload_idf.cpp @@ -337,15 +337,6 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { bool Nextion::upload_end_(bool successful) { ESP_LOGD(TAG, "TFT upload done: %s", YESNO(successful)); - this->is_updating_ = false; - this->ignore_is_setup_ = false; - - uint32_t baud_rate = this->parent_->get_baud_rate(); - if (baud_rate != this->original_baud_rate_) { - ESP_LOGD(TAG, "Baud back: %" PRIu32 "->%" PRIu32, baud_rate, this->original_baud_rate_); - this->parent_->set_baud_rate(this->original_baud_rate_); - this->parent_->load_settings(); - } if (successful) { ESP_LOGD(TAG, "Restart"); @@ -353,7 +344,18 @@ bool Nextion::upload_end_(bool successful) { App.safe_reboot(); } else { ESP_LOGE(TAG, "TFT upload failed"); + + this->is_updating_ = false; + this->ignore_is_setup_ = false; + + uint32_t baud_rate = this->parent_->get_baud_rate(); + if (baud_rate != this->original_baud_rate_) { + ESP_LOGD(TAG, "Baud back: %" PRIu32 "->%" PRIu32, baud_rate, this->original_baud_rate_); + this->parent_->set_baud_rate(this->original_baud_rate_); + this->parent_->load_settings(); + } } + return successful; } From 40db3146b9eccdf0ad9887b9c3add359d02198a9 Mon Sep 17 00:00:00 2001 From: Nate Clark Date: Fri, 13 Jun 2025 14:31:00 -0400 Subject: [PATCH 04/27] Fix BYPASS_AUTO feature to work with or without an arming delay (#9051) --- .../template_alarm_control_panel.cpp | 23 +++++++++++-------- .../template_alarm_control_panel.h | 1 + 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index c550d60630..6f743a77ef 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -110,15 +110,7 @@ void TemplateAlarmControlPanel::loop() { delay = this->arming_night_time_; } if ((millis() - this->last_update_) > delay) { -#ifdef USE_BINARY_SENSOR - for (auto sensor_info : this->sensor_map_) { - // Check for sensors left on and set to bypass automatically and remove them from monitoring - if ((sensor_info.second.flags & BINARY_SENSOR_MODE_BYPASS_AUTO) && (sensor_info.first->state)) { - ESP_LOGW(TAG, "%s is left on and will be automatically bypassed", sensor_info.first->get_name().c_str()); - this->bypassed_sensor_indicies_.push_back(sensor_info.second.store_index); - } - } -#endif + this->bypass_before_arming(); this->publish_state(this->desired_state_); } return; @@ -259,10 +251,23 @@ void TemplateAlarmControlPanel::arm_(optional code, alarm_control_p if (delay > 0) { this->publish_state(ACP_STATE_ARMING); } else { + this->bypass_before_arming(); this->publish_state(state); } } +void TemplateAlarmControlPanel::bypass_before_arming() { +#ifdef USE_BINARY_SENSOR + for (auto sensor_info : this->sensor_map_) { + // Check for sensors left on and set to bypass automatically and remove them from monitoring + if ((sensor_info.second.flags & BINARY_SENSOR_MODE_BYPASS_AUTO) && (sensor_info.first->state)) { + ESP_LOGW(TAG, "'%s' is left on and will be automatically bypassed", sensor_info.first->get_name().c_str()); + this->bypassed_sensor_indicies_.push_back(sensor_info.second.store_index); + } + } +#endif +} + void TemplateAlarmControlPanel::control(const AlarmControlPanelCall &call) { if (call.get_state()) { if (call.get_state() == ACP_STATE_ARMED_AWAY) { diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 0e3a5c77cf..c3b28e8efa 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -60,6 +60,7 @@ class TemplateAlarmControlPanel : public alarm_control_panel::AlarmControlPanel, bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; } bool get_all_sensors_ready() { return this->sensors_ready_; }; void set_restore_mode(TemplateAlarmControlPanelRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } + void bypass_before_arming(); #ifdef USE_BINARY_SENSOR /** Add a binary_sensor to the alarm_panel. From 0a1be3d19ccee5fa17a2ca485dcaca53523515e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Jun 2025 16:10:33 -0500 Subject: [PATCH 05/27] Fix misleading comment in API (#9069) --- esphome/components/api/api_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 93ba9248b4..d09b1107d2 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -260,7 +260,7 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint16_t mes return 0; // Doesn't fit } - // Allocate exact buffer space needed (just the payload, not the overhead) + // Allocate buffer space - pass payload size, allocation functions add header/footer space ProtoWriteBuffer buffer = is_single ? conn->allocate_single_message_buffer(size) : conn->allocate_batch_message_buffer(size); From 5fc1f908226869563db32403d8fc6c94e15cc681 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 14 Jun 2025 11:08:07 +1200 Subject: [PATCH 06/27] [prometheus] Remove ``cv.only_with_arduino`` (#9061) --- esphome/components/prometheus/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index b899fe7642..26a9e70f7c 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -31,7 +31,6 @@ CONFIG_SCHEMA = cv.Schema( } ), }, - cv.only_with_arduino, ).extend(cv.COMPONENT_SCHEMA) From 00e8332bf5780010c358b520d39a1171bac0ed6d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 14 Jun 2025 11:17:06 +1200 Subject: [PATCH 07/27] [esp32] Dynamically set default framework based on variant (#9060) --- esphome/components/esp32/__init__.py | 33 +++++++++++++++++++++++++--- esphome/wizard.py | 16 -------------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index b5d4c83f5e..157fd9db11 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -94,6 +94,13 @@ COMPILER_OPTIMIZATIONS = { "SIZE": "CONFIG_COMPILER_OPTIMIZATION_SIZE", } +ARDUINO_ALLOWED_VARIANTS = [ + VARIANT_ESP32, + VARIANT_ESP32C3, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +] + def get_cpu_frequencies(*frequencies): return [str(x) + "MHZ" for x in frequencies] @@ -143,12 +150,17 @@ def set_core_data(config): CORE.data[KEY_ESP32][KEY_COMPONENTS] = {} elif conf[CONF_TYPE] == FRAMEWORK_ARDUINO: CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" + if variant not in ARDUINO_ALLOWED_VARIANTS: + raise cv.Invalid( + f"ESPHome does not support using the Arduino framework for the {variant}. Please use the ESP-IDF framework instead.", + path=[CONF_FRAMEWORK, CONF_TYPE], + ) CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( config[CONF_FRAMEWORK][CONF_VERSION] ) CORE.data[KEY_ESP32][KEY_BOARD] = config[CONF_BOARD] - CORE.data[KEY_ESP32][KEY_VARIANT] = config[CONF_VARIANT] + CORE.data[KEY_ESP32][KEY_VARIANT] = variant CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES] = {} return config @@ -618,6 +630,21 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All( ) +def _set_default_framework(config): + if CONF_FRAMEWORK not in config: + config = config.copy() + + variant = config[CONF_VARIANT] + if variant in ARDUINO_ALLOWED_VARIANTS: + config[CONF_FRAMEWORK] = ARDUINO_FRAMEWORK_SCHEMA({}) + config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ARDUINO + else: + config[CONF_FRAMEWORK] = ESP_IDF_FRAMEWORK_SCHEMA({}) + config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ESP_IDF + + return config + + FRAMEWORK_ESP_IDF = "esp-idf" FRAMEWORK_ARDUINO = "arduino" FRAMEWORK_SCHEMA = cv.typed_schema( @@ -627,7 +654,6 @@ FRAMEWORK_SCHEMA = cv.typed_schema( }, lower=True, space="-", - default_type=FRAMEWORK_ARDUINO, ) @@ -654,10 +680,11 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_PARTITIONS): cv.file_, cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True), - cv.Optional(CONF_FRAMEWORK, default={}): FRAMEWORK_SCHEMA, + cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA, } ), _detect_variant, + _set_default_framework, set_core_data, ) diff --git a/esphome/wizard.py b/esphome/wizard.py index ca987304e2..7b4d87be63 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -67,20 +67,6 @@ esp8266: """ ESP32_CONFIG = """ -esp32: - board: {board} - framework: - type: arduino -""" - -ESP32S2_CONFIG = """ -esp32: - board: {board} - framework: - type: esp-idf -""" - -ESP32C3_CONFIG = """ esp32: board: {board} framework: @@ -105,8 +91,6 @@ rtl87xx: HARDWARE_BASE_CONFIGS = { "ESP8266": ESP8266_CONFIG, "ESP32": ESP32_CONFIG, - "ESP32S2": ESP32S2_CONFIG, - "ESP32C3": ESP32C3_CONFIG, "RP2040": RP2040_CONFIG, "BK72XX": BK72XX_CONFIG, "RTL87XX": RTL87XX_CONFIG, From ebecf7047ef23cd93941cb526c460777886707c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Jun 2025 08:19:41 -0500 Subject: [PATCH 08/27] Fix `captive_portal` loading entire `web_server` (#9066) --- esphome/components/web_server_idf/__init__.py | 2 -- esphome/components/web_server_idf/web_server_idf.cpp | 8 ++++++-- esphome/components/web_server_idf/web_server_idf.h | 7 +++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 73c51f8cb5..506e1c5c13 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -8,8 +8,6 @@ CONFIG_SCHEMA = cv.All( cv.only_with_esp_idf, ) -AUTO_LOAD = ["web_server"] - async def to_code(config): # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 6bfc49c675..90fdf720cd 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -9,10 +9,12 @@ #include "utils.h" +#include "web_server_idf.h" + +#ifdef USE_WEBSERVER #include "esphome/components/web_server/web_server.h" #include "esphome/components/web_server/list_entities.h" - -#include "web_server_idf.h" +#endif // USE_WEBSERVER namespace esphome { namespace web_server_idf { @@ -273,6 +275,7 @@ void AsyncResponseStream::printf(const char *fmt, ...) { this->print(str); } +#ifdef USE_WEBSERVER AsyncEventSource::~AsyncEventSource() { for (auto *ses : this->sessions_) { delete ses; // NOLINT(cppcoreguidelines-owning-memory) @@ -511,6 +514,7 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e } } } +#endif } // namespace web_server_idf } // namespace esphome diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 13a3ef168d..d883c0ca9b 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -1,6 +1,7 @@ #pragma once #ifdef USE_ESP_IDF +#include "esphome/core/defines.h" #include #include @@ -12,10 +13,12 @@ #include namespace esphome { +#ifdef USE_WEBSERVER namespace web_server { class WebServer; class ListEntitiesIterator; }; // namespace web_server +#endif namespace web_server_idf { #define F(string_literal) (string_literal) @@ -220,6 +223,7 @@ class AsyncWebHandler { virtual bool isRequestHandlerTrivial() { return true; } }; +#ifdef USE_WEBSERVER class AsyncEventSource; class AsyncEventSourceResponse; @@ -307,10 +311,13 @@ class AsyncEventSource : public AsyncWebHandler { connect_handler_t on_connect_{}; esphome::web_server::WebServer *web_server_; }; +#endif // USE_WEBSERVER class DefaultHeaders { friend class AsyncWebServerRequest; +#ifdef USE_WEBSERVER friend class AsyncEventSourceResponse; +#endif public: // NOLINTNEXTLINE(readability-identifier-naming) From 1fdfe7578f05509c6c46b30752b74ac258dea6b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Jun 2025 21:44:45 -0500 Subject: [PATCH 09/27] Make ParseOnOffState enum uint8_t (#9083) --- esphome/core/helpers.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 7d25e7d261..477f260bf0 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -438,7 +438,7 @@ template::value, int> = 0> std::stri } /// Return values for parse_on_off(). -enum ParseOnOffState { +enum ParseOnOffState : uint8_t { PARSE_NONE = 0, PARSE_ON, PARSE_OFF, From 77740a1044970c727a62649324078dcd7df528a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Jun 2025 21:48:53 -0500 Subject: [PATCH 10/27] Optimize Component and Application state storage from uint32_t to uint8_t (#9082) --- .../components/bme280_base/bme280_base.cpp | 5 +-- esphome/components/kmeteriso/kmeteriso.cpp | 5 +-- .../status_led/light/status_led_light.cpp | 4 +- .../status_led/light/status_led_light.h | 2 +- esphome/components/weikai/weikai.cpp | 2 +- esphome/core/application.cpp | 4 +- esphome/core/application.h | 4 +- esphome/core/component.cpp | 36 +++++++++++------ esphome/core/component.h | 39 +++++++++++++------ 9 files changed, 65 insertions(+), 36 deletions(-) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 142a03fe1c..d2524e5aac 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -93,9 +93,8 @@ void BME280Component::setup() { // Mark as not failed before initializing. Some devices will turn off sensors to save on batteries // and when they come back on, the COMPONENT_STATE_FAILED bit must be unset on the component. - if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED) { - this->component_state_ &= ~COMPONENT_STATE_MASK; - this->component_state_ |= COMPONENT_STATE_CONSTRUCTION; + if (this->is_failed()) { + this->reset_to_construction_state(); } if (!this->read_byte(BME280_REGISTER_CHIPID, &chip_id)) { diff --git a/esphome/components/kmeteriso/kmeteriso.cpp b/esphome/components/kmeteriso/kmeteriso.cpp index b3fbc31fe6..714df0b538 100644 --- a/esphome/components/kmeteriso/kmeteriso.cpp +++ b/esphome/components/kmeteriso/kmeteriso.cpp @@ -19,9 +19,8 @@ void KMeterISOComponent::setup() { // Mark as not failed before initializing. Some devices will turn off sensors to save on batteries // and when they come back on, the COMPONENT_STATE_FAILED bit must be unset on the component. - if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED) { - this->component_state_ &= ~COMPONENT_STATE_MASK; - this->component_state_ |= COMPONENT_STATE_CONSTRUCTION; + if (this->is_failed()) { + this->reset_to_construction_state(); } auto err = this->bus_->writev(this->address_, nullptr, 0); diff --git a/esphome/components/status_led/light/status_led_light.cpp b/esphome/components/status_led/light/status_led_light.cpp index 6d38833ebd..dc4820f6da 100644 --- a/esphome/components/status_led/light/status_led_light.cpp +++ b/esphome/components/status_led/light/status_led_light.cpp @@ -9,10 +9,10 @@ namespace status_led { static const char *const TAG = "status_led"; void StatusLEDLightOutput::loop() { - uint32_t new_state = App.get_app_state() & STATUS_LED_MASK; + uint8_t new_state = App.get_app_state() & STATUS_LED_MASK; if (new_state != this->last_app_state_) { - ESP_LOGV(TAG, "New app state 0x%08" PRIX32, new_state); + ESP_LOGV(TAG, "New app state 0x%02X", new_state); } if ((new_state & STATUS_LED_ERROR) != 0u) { diff --git a/esphome/components/status_led/light/status_led_light.h b/esphome/components/status_led/light/status_led_light.h index e711a2e749..bfa144526a 100644 --- a/esphome/components/status_led/light/status_led_light.h +++ b/esphome/components/status_led/light/status_led_light.h @@ -36,7 +36,7 @@ class StatusLEDLightOutput : public light::LightOutput, public Component { GPIOPin *pin_{nullptr}; output::BinaryOutput *output_{nullptr}; light::LightState *lightstate_{}; - uint32_t last_app_state_{0xFFFF}; + uint8_t last_app_state_{0xFF}; void output_state_(bool state); }; diff --git a/esphome/components/weikai/weikai.cpp b/esphome/components/weikai/weikai.cpp index 2211fc77d5..ebe987cc65 100644 --- a/esphome/components/weikai/weikai.cpp +++ b/esphome/components/weikai/weikai.cpp @@ -102,7 +102,7 @@ WeikaiRegister &WeikaiRegister::operator|=(uint8_t value) { // The WeikaiComponent methods /////////////////////////////////////////////////////////////////////////////// void WeikaiComponent::loop() { - if ((this->component_state_ & COMPONENT_STATE_MASK) != COMPONENT_STATE_LOOP) + if (!this->is_in_loop_state()) return; // If there are some bytes in the receive FIFO we transfers them to the ring buffers diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 87e6f33e04..4ed96f7300 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -66,7 +66,7 @@ void Application::setup() { [](Component *a, Component *b) { return a->get_loop_priority() > b->get_loop_priority(); }); do { - uint32_t new_app_state = STATUS_LED_WARNING; + uint8_t new_app_state = STATUS_LED_WARNING; this->scheduler.call(); this->feed_wdt(); for (uint32_t j = 0; j <= i; j++) { @@ -87,7 +87,7 @@ void Application::setup() { this->calculate_looping_components_(); } void Application::loop() { - uint32_t new_app_state = 0; + uint8_t new_app_state = 0; this->scheduler.call(); diff --git a/esphome/core/application.h b/esphome/core/application.h index 6c09b25590..d9ef4fe036 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -332,7 +332,7 @@ class Application { */ void teardown_components(uint32_t timeout_ms); - uint32_t get_app_state() const { return this->app_state_; } + uint8_t get_app_state() const { return this->app_state_; } #ifdef USE_BINARY_SENSOR const std::vector &get_binary_sensors() { return this->binary_sensors_; } @@ -653,7 +653,7 @@ class Application { uint32_t last_loop_{0}; uint32_t loop_interval_{16}; size_t dump_config_at_{SIZE_MAX}; - uint32_t app_state_{0}; + uint8_t app_state_{0}; Component *current_component_{nullptr}; uint32_t loop_component_start_time_{0}; diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 1141e4067d..dae99a0d22 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -29,15 +29,17 @@ const float LATE = -100.0f; } // namespace setup_priority -const uint32_t COMPONENT_STATE_MASK = 0xFF; -const uint32_t COMPONENT_STATE_CONSTRUCTION = 0x00; -const uint32_t COMPONENT_STATE_SETUP = 0x01; -const uint32_t COMPONENT_STATE_LOOP = 0x02; -const uint32_t COMPONENT_STATE_FAILED = 0x03; -const uint32_t STATUS_LED_MASK = 0xFF00; -const uint32_t STATUS_LED_OK = 0x0000; -const uint32_t STATUS_LED_WARNING = 0x0100; -const uint32_t STATUS_LED_ERROR = 0x0200; +// Component state uses bits 0-1 (4 states) +const uint8_t COMPONENT_STATE_MASK = 0x03; +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 STATUS_LED_OK = 0x00; +const uint8_t STATUS_LED_WARNING = 0x04; // Bit 2 +const uint8_t STATUS_LED_ERROR = 0x08; // Bit 3 const uint32_t WARN_IF_BLOCKING_OVER_MS = 50U; ///< Initial blocking time allowed without warning const uint32_t WARN_IF_BLOCKING_INCREMENT_MS = 10U; ///< How long the blocking time must be larger to warn again @@ -86,9 +88,9 @@ void Component::call_dump_config() { } } -uint32_t Component::get_component_state() const { return this->component_state_; } +uint8_t Component::get_component_state() const { return this->component_state_; } void Component::call() { - uint32_t state = this->component_state_ & COMPONENT_STATE_MASK; + uint8_t state = this->component_state_ & COMPONENT_STATE_MASK; switch (state) { case COMPONENT_STATE_CONSTRUCTION: // State Construction: Call setup and set state to setup @@ -131,6 +133,18 @@ void Component::mark_failed() { this->component_state_ |= COMPONENT_STATE_FAILED; this->status_set_error(); } +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()); + this->component_state_ &= ~COMPONENT_STATE_MASK; + this->component_state_ |= COMPONENT_STATE_CONSTRUCTION; + // Clear error status when resetting + this->status_clear_error(); + } +} +bool Component::is_in_loop_state() const { + return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP; +} void Component::defer(std::function &&f) { // NOLINT App.scheduler.set_timeout(this, "", 0, std::move(f)); } diff --git a/esphome/core/component.h b/esphome/core/component.h index ce9f0289d0..7ad4a5e496 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -53,15 +53,15 @@ static const uint32_t SCHEDULER_DONT_RUN = 4294967295UL; ESP_LOGCONFIG(TAG, " Update Interval: %.1fs", this->get_update_interval() / 1000.0f); \ } -extern const uint32_t COMPONENT_STATE_MASK; -extern const uint32_t COMPONENT_STATE_CONSTRUCTION; -extern const uint32_t COMPONENT_STATE_SETUP; -extern const uint32_t COMPONENT_STATE_LOOP; -extern const uint32_t COMPONENT_STATE_FAILED; -extern const uint32_t STATUS_LED_MASK; -extern const uint32_t STATUS_LED_OK; -extern const uint32_t STATUS_LED_WARNING; -extern const uint32_t STATUS_LED_ERROR; +extern const uint8_t COMPONENT_STATE_MASK; +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 STATUS_LED_MASK; +extern const uint8_t STATUS_LED_OK; +extern const uint8_t STATUS_LED_WARNING; +extern const uint8_t STATUS_LED_ERROR; enum class RetryResult { DONE, RETRY }; @@ -123,7 +123,19 @@ class Component { */ virtual void on_powerdown() {} - uint32_t get_component_state() const; + uint8_t get_component_state() const; + + /** Reset this component back to the construction state to allow setup to run again. + * + * This can be used by components that have recoverable failures to attempt setup again. + */ + void reset_to_construction_state(); + + /** Check if this component has completed setup and is in the loop state. + * + * @return True if in loop state, false otherwise. + */ + bool is_in_loop_state() const; /** Mark this component as failed. Any future timeouts/intervals/setup/loop will no longer be called. * @@ -298,7 +310,12 @@ class Component { /// Cancel a defer callback using the specified name, name must not be empty. bool cancel_defer(const std::string &name); // NOLINT - uint32_t component_state_{0x0000}; ///< State of this component. + /// State of this component - each bit has a purpose: + /// Bits 0-1: Component state (0x00=CONSTRUCTION, 0x01=SETUP, 0x02=LOOP, 0x03=FAILED) + /// Bit 2: STATUS_LED_WARNING + /// Bit 3: STATUS_LED_ERROR + /// Bits 4-7: Unused - reserved for future expansion (50% of the bits are free) + uint8_t component_state_{0x00}; float setup_priority_override_{NAN}; const char *component_source_{nullptr}; uint32_t warn_if_blocking_over_{WARN_IF_BLOCKING_OVER_MS}; From fb12e4e66a374b1ef8a2459b21ef4789b3174448 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Jun 2025 21:55:03 -0500 Subject: [PATCH 11/27] Small optimizations to api buffer helper (#9071) --- esphome/components/api/api_connection.h | 40 +++++++++++-------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 34c7dcd880..13e6066788 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -240,8 +240,8 @@ class APIConnection : public APIServerConnection { // - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext) // - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext) shared_buf.reserve(reserve_size + header_padding + this->helper_->frame_footer_size()); - // Insert header padding bytes so message encoding starts at the correct position - shared_buf.insert(shared_buf.begin(), header_padding, 0); + // Resize to add header padding so message encoding starts at the correct position + shared_buf.resize(header_padding); return {&shared_buf}; } @@ -249,32 +249,26 @@ class APIConnection : public APIServerConnection { ProtoWriteBuffer prepare_message_buffer(uint16_t message_size, bool is_first_message) { // Get reference to shared buffer (it maintains state between batch messages) std::vector &shared_buf = this->parent_->get_shared_buffer_ref(); - size_t current_size = shared_buf.size(); if (is_first_message) { - // For first message, initialize buffer with header padding - uint8_t header_padding = this->helper_->frame_header_padding(); shared_buf.clear(); - shared_buf.reserve(message_size + header_padding); - shared_buf.resize(header_padding); - // Fill header padding with zeros - std::fill(shared_buf.begin(), shared_buf.end(), 0); - } else { - // For subsequent messages, add footer space for previous message and header for this message - uint8_t footer_size = this->helper_->frame_footer_size(); - uint8_t header_padding = this->helper_->frame_header_padding(); - - // Reserve additional space for everything - shared_buf.reserve(current_size + footer_size + header_padding + message_size); - - // Single resize to add both footer and header padding - size_t new_size = current_size + footer_size + header_padding; - shared_buf.resize(new_size); - - // Fill the newly added bytes with zeros (footer + header padding) - std::fill(shared_buf.begin() + current_size, shared_buf.end(), 0); } + size_t current_size = shared_buf.size(); + + // Calculate padding to add: + // - First message: just header padding + // - Subsequent messages: footer for previous message + header padding for this message + size_t padding_to_add = is_first_message + ? this->helper_->frame_header_padding() + : this->helper_->frame_header_padding() + this->helper_->frame_footer_size(); + + // Reserve space for padding + message + shared_buf.reserve(current_size + padding_to_add + message_size); + + // Resize to add the padding bytes + shared_buf.resize(current_size + padding_to_add); + return {&shared_buf}; } From 4787e22f61e57e46dbecfd91a981a8aee57f54f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Jun 2025 22:21:55 -0500 Subject: [PATCH 12/27] Reduce entity memory usage by eliminating field shadowing and bit-packing (#9076) --- esphome/components/datetime/date_entity.cpp | 10 +- esphome/components/datetime/datetime_base.h | 5 - .../components/datetime/datetime_entity.cpp | 16 +-- esphome/components/datetime/time_entity.cpp | 8 +- .../components/esp32_camera/esp32_camera.cpp | 2 +- .../binary_sensor/nextion_binarysensor.cpp | 2 +- .../nextion/sensor/nextion_sensor.cpp | 2 +- .../text_sensor/nextion_textsensor.cpp | 2 +- esphome/components/number/number.cpp | 2 +- esphome/components/number/number.h | 4 - esphome/components/select/select.cpp | 2 +- esphome/components/select/select.h | 4 - esphome/components/sensor/sensor.cpp | 3 +- esphome/components/sensor/sensor.h | 4 - esphome/components/text/text.cpp | 2 +- esphome/components/text/text.h | 4 - .../components/text_sensor/text_sensor.cpp | 3 +- esphome/components/text_sensor/text_sensor.h | 4 - esphome/components/update/update_entity.cpp | 2 +- esphome/components/update/update_entity.h | 3 - .../uptime/sensor/uptime_timestamp_sensor.cpp | 2 +- esphome/core/entity_base.cpp | 20 +--- esphome/core/entity_base.h | 40 ++++--- .../fixtures/host_mode_entity_fields.yaml | 108 ++++++++++++++++++ .../test_host_mode_entity_fields.py | 93 +++++++++++++++ 25 files changed, 260 insertions(+), 87 deletions(-) create mode 100644 tests/integration/fixtures/host_mode_entity_fields.yaml create mode 100644 tests/integration/test_host_mode_entity_fields.py diff --git a/esphome/components/datetime/date_entity.cpp b/esphome/components/datetime/date_entity.cpp index b5bcef43af..c164a98b2e 100644 --- a/esphome/components/datetime/date_entity.cpp +++ b/esphome/components/datetime/date_entity.cpp @@ -11,25 +11,25 @@ static const char *const TAG = "datetime.date_entity"; void DateEntity::publish_state() { if (this->year_ == 0 || this->month_ == 0 || this->day_ == 0) { - this->has_state_ = false; + this->set_has_state(false); return; } if (this->year_ < 1970 || this->year_ > 3000) { - this->has_state_ = false; + this->set_has_state(false); ESP_LOGE(TAG, "Year must be between 1970 and 3000"); return; } if (this->month_ < 1 || this->month_ > 12) { - this->has_state_ = false; + this->set_has_state(false); ESP_LOGE(TAG, "Month must be between 1 and 12"); return; } if (this->day_ > days_in_month(this->month_, this->year_)) { - this->has_state_ = false; + this->set_has_state(false); ESP_LOGE(TAG, "Day must be between 1 and %d for month %d", days_in_month(this->month_, this->year_), this->month_); return; } - this->has_state_ = true; + this->set_has_state(true); ESP_LOGD(TAG, "'%s': Sending date %d-%d-%d", this->get_name().c_str(), this->year_, this->month_, this->day_); this->state_callback_.call(); } diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index dea34e6110..b7645f5539 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -13,9 +13,6 @@ namespace datetime { class DateTimeBase : public EntityBase { public: - /// Return whether this Datetime has gotten a full state yet. - bool has_state() const { return this->has_state_; } - virtual ESPTime state_as_esptime() const = 0; void add_on_state_callback(std::function &&callback) { this->state_callback_.add(std::move(callback)); } @@ -31,8 +28,6 @@ class DateTimeBase : public EntityBase { #ifdef USE_TIME time::RealTimeClock *rtc_; #endif - - bool has_state_{false}; }; #ifdef USE_TIME diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index 3d92194efa..4e3b051eb3 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -11,40 +11,40 @@ static const char *const TAG = "datetime.datetime_entity"; void DateTimeEntity::publish_state() { if (this->year_ == 0 || this->month_ == 0 || this->day_ == 0) { - this->has_state_ = false; + this->set_has_state(false); return; } if (this->year_ < 1970 || this->year_ > 3000) { - this->has_state_ = false; + this->set_has_state(false); ESP_LOGE(TAG, "Year must be between 1970 and 3000"); return; } if (this->month_ < 1 || this->month_ > 12) { - this->has_state_ = false; + this->set_has_state(false); ESP_LOGE(TAG, "Month must be between 1 and 12"); return; } if (this->day_ > days_in_month(this->month_, this->year_)) { - this->has_state_ = false; + this->set_has_state(false); ESP_LOGE(TAG, "Day must be between 1 and %d for month %d", days_in_month(this->month_, this->year_), this->month_); return; } if (this->hour_ > 23) { - this->has_state_ = false; + this->set_has_state(false); ESP_LOGE(TAG, "Hour must be between 0 and 23"); return; } if (this->minute_ > 59) { - this->has_state_ = false; + this->set_has_state(false); ESP_LOGE(TAG, "Minute must be between 0 and 59"); return; } if (this->second_ > 59) { - this->has_state_ = false; + this->set_has_state(false); ESP_LOGE(TAG, "Second must be between 0 and 59"); return; } - this->has_state_ = true; + this->set_has_state(true); ESP_LOGD(TAG, "'%s': Sending datetime %04u-%02u-%02u %02d:%02d:%02d", this->get_name().c_str(), this->year_, this->month_, this->day_, this->hour_, this->minute_, this->second_); this->state_callback_.call(); diff --git a/esphome/components/datetime/time_entity.cpp b/esphome/components/datetime/time_entity.cpp index db0094ae01..9b05c2124f 100644 --- a/esphome/components/datetime/time_entity.cpp +++ b/esphome/components/datetime/time_entity.cpp @@ -11,21 +11,21 @@ static const char *const TAG = "datetime.time_entity"; void TimeEntity::publish_state() { if (this->hour_ > 23) { - this->has_state_ = false; + this->set_has_state(false); ESP_LOGE(TAG, "Hour must be between 0 and 23"); return; } if (this->minute_ > 59) { - this->has_state_ = false; + this->set_has_state(false); ESP_LOGE(TAG, "Minute must be between 0 and 59"); return; } if (this->second_ > 59) { - this->has_state_ = false; + this->set_has_state(false); ESP_LOGE(TAG, "Second must be between 0 and 59"); return; } - this->has_state_ = true; + this->set_has_state(true); ESP_LOGD(TAG, "'%s': Sending time %02d:%02d:%02d", this->get_name().c_str(), this->hour_, this->minute_, this->second_); this->state_callback_.call(); diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index a7551571dd..da0f277358 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -57,7 +57,7 @@ void ESP32Camera::dump_config() { " External Clock: Pin:%d Frequency:%u\n" " I2C Pins: SDA:%d SCL:%d\n" " Reset Pin: %d", - this->name_.c_str(), YESNO(this->internal_), conf.pin_d0, conf.pin_d1, conf.pin_d2, conf.pin_d3, + this->name_.c_str(), YESNO(this->is_internal()), conf.pin_d0, conf.pin_d1, conf.pin_d2, conf.pin_d3, conf.pin_d4, conf.pin_d5, conf.pin_d6, conf.pin_d7, conf.pin_vsync, conf.pin_href, conf.pin_pclk, conf.pin_xclk, conf.xclk_freq_hz, conf.pin_sccb_sda, conf.pin_sccb_scl, conf.pin_reset); switch (this->config_.frame_size) { diff --git a/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp b/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp index ab1e20859c..b6d4cc3f23 100644 --- a/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp +++ b/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp @@ -56,7 +56,7 @@ void NextionBinarySensor::set_state(bool state, bool publish, bool send_to_nexti this->publish_state(state); } else { this->state = state; - this->has_state_ = true; + this->set_has_state(true); } this->update_component_settings(); diff --git a/esphome/components/nextion/sensor/nextion_sensor.cpp b/esphome/components/nextion/sensor/nextion_sensor.cpp index 9be49e3476..0ed9da95d4 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.cpp +++ b/esphome/components/nextion/sensor/nextion_sensor.cpp @@ -88,7 +88,7 @@ void NextionSensor::set_state(float state, bool publish, bool send_to_nextion) { } else { this->raw_state = state; this->state = state; - this->has_state_ = true; + this->set_has_state(true); } } this->update_component_settings(); diff --git a/esphome/components/nextion/text_sensor/nextion_textsensor.cpp b/esphome/components/nextion/text_sensor/nextion_textsensor.cpp index a1d45f55e0..e08cbb02ca 100644 --- a/esphome/components/nextion/text_sensor/nextion_textsensor.cpp +++ b/esphome/components/nextion/text_sensor/nextion_textsensor.cpp @@ -37,7 +37,7 @@ void NextionTextSensor::set_state(const std::string &state, bool publish, bool s this->publish_state(state); } else { this->state = state; - this->has_state_ = true; + this->set_has_state(true); } this->update_component_settings(); diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index fda4f43e34..b6a845b19b 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -7,7 +7,7 @@ namespace number { static const char *const TAG = "number"; void Number::publish_state(float state) { - this->has_state_ = true; + this->set_has_state(true); this->state = state; ESP_LOGD(TAG, "'%s': Sending state %f", this->get_name().c_str(), state); this->state_callback_.call(state); diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index d839d12ad1..49bcbb857c 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -48,9 +48,6 @@ class Number : public EntityBase { NumberTraits traits; - /// Return whether this number has gotten a full state yet. - bool has_state() const { return has_state_; } - protected: friend class NumberCall; @@ -63,7 +60,6 @@ class Number : public EntityBase { virtual void control(float value) = 0; CallbackManager state_callback_; - bool has_state_{false}; }; } // namespace number diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 806882ad94..37887da27c 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -10,7 +10,7 @@ void Select::publish_state(const std::string &state) { auto index = this->index_of(state); const auto *name = this->get_name().c_str(); if (index.has_value()) { - this->has_state_ = true; + this->set_has_state(true); this->state = state; ESP_LOGD(TAG, "'%s': Sending state %s (index %zu)", name, state.c_str(), index.value()); this->state_callback_.call(state, index.value()); diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 8ca9a69d1c..3ab651b241 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -35,9 +35,6 @@ class Select : public EntityBase { void publish_state(const std::string &state); - /// Return whether this select component has gotten a full state yet. - bool has_state() const { return has_state_; } - /// Instantiate a SelectCall object to modify this select component's state. SelectCall make_call() { return SelectCall(this); } @@ -73,7 +70,6 @@ class Select : public EntityBase { virtual void control(const std::string &value) = 0; CallbackManager state_callback_; - bool has_state_{false}; }; } // namespace select diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 14a8b3d490..251ef47ecc 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -88,13 +88,12 @@ float Sensor::get_raw_state() const { return this->raw_state; } std::string Sensor::unique_id() { return ""; } void Sensor::internal_send_state_to_frontend(float state) { - this->has_state_ = true; + this->set_has_state(true); this->state = state; ESP_LOGD(TAG, "'%s': Sending state %.5f %s with %d decimals of accuracy", this->get_name().c_str(), state, this->get_unit_of_measurement().c_str(), this->get_accuracy_decimals()); this->callback_.call(state); } -bool Sensor::has_state() const { return this->has_state_; } } // namespace sensor } // namespace esphome diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index ab9ff1565c..ac61548a55 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -140,9 +140,6 @@ class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBa */ float raw_state; - /// Return whether this sensor has gotten a full state (that passed through all filters) yet. - bool has_state() const; - /** Override this method to set the unique ID of this sensor. * * @deprecated Do not use for new sensors, a suitable unique ID is automatically generated (2023.4). @@ -160,7 +157,6 @@ class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBa optional accuracy_decimals_; ///< Accuracy in decimals override optional state_class_{STATE_CLASS_NONE}; ///< State class override bool force_update_{false}; ///< Force update mode - bool has_state_{false}; }; } // namespace sensor diff --git a/esphome/components/text/text.cpp b/esphome/components/text/text.cpp index 8f0242e747..654893d4e4 100644 --- a/esphome/components/text/text.cpp +++ b/esphome/components/text/text.cpp @@ -7,7 +7,7 @@ namespace text { static const char *const TAG = "text"; void Text::publish_state(const std::string &state) { - this->has_state_ = true; + this->set_has_state(true); this->state = state; if (this->traits.get_mode() == TEXT_MODE_PASSWORD) { ESP_LOGD(TAG, "'%s': Sending state " LOG_SECRET("'%s'"), this->get_name().c_str(), state.c_str()); diff --git a/esphome/components/text/text.h b/esphome/components/text/text.h index f71dde69ba..3cc0cefc3e 100644 --- a/esphome/components/text/text.h +++ b/esphome/components/text/text.h @@ -28,9 +28,6 @@ class Text : public EntityBase { void publish_state(const std::string &state); - /// Return whether this text input has gotten a full state yet. - bool has_state() const { return has_state_; } - /// Instantiate a TextCall object to modify this text component's state. TextCall make_call() { return TextCall(this); } @@ -48,7 +45,6 @@ class Text : public EntityBase { virtual void control(const std::string &value) = 0; CallbackManager state_callback_; - bool has_state_{false}; }; } // namespace text diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index f10cd50267..1138ada281 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -60,13 +60,12 @@ std::string TextSensor::get_state() const { return this->state; } std::string TextSensor::get_raw_state() const { return this->raw_state; } void TextSensor::internal_send_state_to_frontend(const std::string &state) { this->state = state; - this->has_state_ = true; + this->set_has_state(true); ESP_LOGD(TAG, "'%s': Sending state '%s'", this->name_.c_str(), state.c_str()); this->callback_.call(state); } std::string TextSensor::unique_id() { return ""; } -bool TextSensor::has_state() { return this->has_state_; } } // namespace text_sensor } // namespace esphome diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index bd72ea70e3..5e45968ef4 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -67,8 +67,6 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { */ virtual std::string unique_id(); - bool has_state(); - void internal_send_state_to_frontend(const std::string &state); protected: @@ -76,8 +74,6 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { CallbackManager callback_; ///< Storage for filtered state callbacks. Filter *filter_list_{nullptr}; ///< Store all active filters. - - bool has_state_{false}; }; } // namespace text_sensor diff --git a/esphome/components/update/update_entity.cpp b/esphome/components/update/update_entity.cpp index ed9a0480d8..ce97fb1b77 100644 --- a/esphome/components/update/update_entity.cpp +++ b/esphome/components/update/update_entity.cpp @@ -30,7 +30,7 @@ void UpdateEntity::publish_state() { ESP_LOGD(TAG, " Progress: %.0f%%", this->update_info_.progress); } - this->has_state_ = true; + this->set_has_state(true); this->state_callback_.call(); } diff --git a/esphome/components/update/update_entity.h b/esphome/components/update/update_entity.h index cc269e288f..169e580457 100644 --- a/esphome/components/update/update_entity.h +++ b/esphome/components/update/update_entity.h @@ -28,8 +28,6 @@ enum UpdateState : uint8_t { class UpdateEntity : public EntityBase, public EntityBase_DeviceClass { public: - bool has_state() const { return this->has_state_; } - void publish_state(); void perform() { this->perform(false); } @@ -44,7 +42,6 @@ class UpdateEntity : public EntityBase, public EntityBase_DeviceClass { protected: UpdateState state_{UPDATE_STATE_UNKNOWN}; UpdateInfo update_info_; - bool has_state_{false}; CallbackManager state_callback_{}; }; diff --git a/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp b/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp index fa8cb2bb61..69033be11c 100644 --- a/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp +++ b/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp @@ -13,7 +13,7 @@ static const char *const TAG = "uptime.sensor"; void UptimeTimestampSensor::setup() { this->time_->add_on_time_sync_callback([this]() { - if (this->has_state_) + if (this->has_state()) return; // No need to update the timestamp if it's already set auto now = this->time_->now(); diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 725a8569a3..791b6615a1 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -12,20 +12,12 @@ void EntityBase::set_name(const char *name) { this->name_ = StringRef(name); if (this->name_.empty()) { this->name_ = StringRef(App.get_friendly_name()); - this->has_own_name_ = false; + this->flags_.has_own_name = false; } else { - this->has_own_name_ = true; + this->flags_.has_own_name = true; } } -// Entity Internal -bool EntityBase::is_internal() const { return this->internal_; } -void EntityBase::set_internal(bool internal) { this->internal_ = internal; } - -// Entity Disabled by Default -bool EntityBase::is_disabled_by_default() const { return this->disabled_by_default_; } -void EntityBase::set_disabled_by_default(bool disabled_by_default) { this->disabled_by_default_ = disabled_by_default; } - // Entity Icon std::string EntityBase::get_icon() const { if (this->icon_c_str_ == nullptr) { @@ -35,14 +27,10 @@ std::string EntityBase::get_icon() const { } void EntityBase::set_icon(const char *icon) { this->icon_c_str_ = icon; } -// Entity Category -EntityCategory EntityBase::get_entity_category() const { return this->entity_category_; } -void EntityBase::set_entity_category(EntityCategory entity_category) { this->entity_category_ = entity_category; } - // Entity Object ID std::string EntityBase::get_object_id() const { // Check if `App.get_friendly_name()` is constant or dynamic. - if (!this->has_own_name_ && App.is_name_add_mac_suffix_enabled()) { + if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { // `App.get_friendly_name()` is dynamic. return str_sanitize(str_snake_case(App.get_friendly_name())); } else { @@ -61,7 +49,7 @@ void EntityBase::set_object_id(const char *object_id) { // Calculate Object ID Hash from Entity Name void EntityBase::calc_object_id_() { // Check if `App.get_friendly_name()` is constant or dynamic. - if (!this->has_own_name_ && App.is_name_add_mac_suffix_enabled()) { + if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { // `App.get_friendly_name()` is dynamic. const auto object_id = str_sanitize(str_snake_case(App.get_friendly_name())); // FNV-1 hash diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 4ca21f9ee5..6b876a9267 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -20,7 +20,7 @@ class EntityBase { void set_name(const char *name); // Get whether this Entity has its own name or it should use the device friendly_name. - bool has_own_name() const { return this->has_own_name_; } + bool has_own_name() const { return this->flags_.has_own_name; } // Get the sanitized name of this Entity as an ID. std::string get_object_id() const; @@ -29,24 +29,32 @@ class EntityBase { // Get the unique Object ID of this Entity uint32_t get_object_id_hash(); - // Get/set whether this Entity should be hidden from outside of ESPHome - bool is_internal() const; - void set_internal(bool internal); + // Get/set whether this Entity should be hidden outside ESPHome + bool is_internal() const { return this->flags_.internal; } + void set_internal(bool internal) { this->flags_.internal = internal; } // Check if this object is declared to be disabled by default. // That means that when the device gets added to Home Assistant (or other clients) it should // not be added to the default view by default, and a user action is necessary to manually add it. - bool is_disabled_by_default() const; - void set_disabled_by_default(bool disabled_by_default); + bool is_disabled_by_default() const { return this->flags_.disabled_by_default; } + void set_disabled_by_default(bool disabled_by_default) { this->flags_.disabled_by_default = disabled_by_default; } // Get/set the entity category. - EntityCategory get_entity_category() const; - void set_entity_category(EntityCategory entity_category); + EntityCategory get_entity_category() const { return static_cast(this->flags_.entity_category); } + void set_entity_category(EntityCategory entity_category) { + this->flags_.entity_category = static_cast(entity_category); + } // Get/set this entity's icon std::string get_icon() const; void set_icon(const char *icon); + // Check if this entity has state + bool has_state() const { return this->flags_.has_state; } + + // Set has_state - for components that need to manually set this + void set_has_state(bool state) { this->flags_.has_state = state; } + protected: /// The hash_base() function has been deprecated. It is kept in this /// class for now, to prevent external components from not compiling. @@ -56,11 +64,17 @@ class EntityBase { StringRef name_; const char *object_id_c_str_{nullptr}; const char *icon_c_str_{nullptr}; - uint32_t object_id_hash_; - bool has_own_name_{false}; - bool internal_{false}; - bool disabled_by_default_{false}; - EntityCategory entity_category_{ENTITY_CATEGORY_NONE}; + uint32_t object_id_hash_{}; + + // Bit-packed flags to save memory (1 byte instead of 5) + struct EntityFlags { + uint8_t has_own_name : 1; + uint8_t internal : 1; + uint8_t disabled_by_default : 1; + uint8_t has_state : 1; + uint8_t entity_category : 2; // Supports up to 4 categories + uint8_t reserved : 2; // Reserved for future use + } flags_{}; }; class EntityBase_DeviceClass { // NOLINT(readability-identifier-naming) diff --git a/tests/integration/fixtures/host_mode_entity_fields.yaml b/tests/integration/fixtures/host_mode_entity_fields.yaml new file mode 100644 index 0000000000..0bd87ee794 --- /dev/null +++ b/tests/integration/fixtures/host_mode_entity_fields.yaml @@ -0,0 +1,108 @@ +esphome: + name: host-test + +host: + +api: + +logger: + +# Test various entity types with different flag combinations +sensor: + - platform: template + name: "Test Normal Sensor" + id: normal_sensor + update_interval: 1s + lambda: |- + return 42.0; + + - platform: template + name: "Test Internal Sensor" + id: internal_sensor + internal: true + update_interval: 1s + lambda: |- + return 43.0; + + - platform: template + name: "Test Disabled Sensor" + id: disabled_sensor + disabled_by_default: true + update_interval: 1s + lambda: |- + return 44.0; + + - platform: template + name: "Test Mixed Flags Sensor" + id: mixed_flags_sensor + internal: true + entity_category: diagnostic + update_interval: 1s + lambda: |- + return 45.0; + + - platform: template + name: "Test Diagnostic Sensor" + id: diagnostic_sensor + entity_category: diagnostic + update_interval: 1s + lambda: |- + return 46.0; + + - platform: template + name: "Test All Flags Sensor" + id: all_flags_sensor + internal: true + disabled_by_default: true + entity_category: diagnostic + update_interval: 1s + lambda: |- + return 47.0; + +# Also test other entity types to ensure bit-packing works across all +binary_sensor: + - platform: template + name: "Test Binary Sensor" + entity_category: config + lambda: |- + return true; + +text_sensor: + - platform: template + name: "Test Text Sensor" + disabled_by_default: true + lambda: |- + return {"Hello"}; + +number: + - platform: template + name: "Test Number" + initial_value: 50 + min_value: 0 + max_value: 100 + step: 1 + optimistic: true + entity_category: diagnostic + +select: + - platform: template + name: "Test Select" + options: + - "Option 1" + - "Option 2" + initial_option: "Option 1" + optimistic: true + internal: true + +switch: + - platform: template + name: "Test Switch" + optimistic: true + disabled_by_default: true + entity_category: config + +button: + - platform: template + name: "Test Button" + on_press: + - logger.log: "Button pressed" diff --git a/tests/integration/test_host_mode_entity_fields.py b/tests/integration/test_host_mode_entity_fields.py new file mode 100644 index 0000000000..cf3fa6916a --- /dev/null +++ b/tests/integration/test_host_mode_entity_fields.py @@ -0,0 +1,93 @@ +"""Integration test for entity bit-packed fields.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityCategory, EntityState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_mode_entity_fields( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test entity bit-packed fields work correctly with all possible values.""" + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Get all entities + entities = await client.list_entities_services() + + # Create a map of entity names to entity info + entity_map = {} + for entity in entities[0]: + if hasattr(entity, "name"): + entity_map[entity.name] = entity + + # Test entities that should be visible via API (non-internal) + visible_test_cases = [ + # (entity_name, expected_disabled_by_default, expected_entity_category) + ("Test Normal Sensor", False, EntityCategory.NONE), + ("Test Disabled Sensor", True, EntityCategory.NONE), + ("Test Diagnostic Sensor", False, EntityCategory.DIAGNOSTIC), + ("Test Switch", True, EntityCategory.CONFIG), + ("Test Binary Sensor", False, EntityCategory.CONFIG), + ("Test Number", False, EntityCategory.DIAGNOSTIC), + ] + + # Test entities that should NOT be visible via API (internal) + internal_entities = [ + "Test Internal Sensor", + "Test Mixed Flags Sensor", + "Test All Flags Sensor", + "Test Select", + ] + + # Verify visible entities + for entity_name, expected_disabled, expected_category in visible_test_cases: + assert entity_name in entity_map, ( + f"Entity '{entity_name}' not found - it should be visible via API" + ) + entity = entity_map[entity_name] + + # Check disabled_by_default flag + assert entity.disabled_by_default == expected_disabled, ( + f"{entity_name}: disabled_by_default flag mismatch - " + f"expected {expected_disabled}, got {entity.disabled_by_default}" + ) + + # Check entity_category + assert entity.entity_category == expected_category, ( + f"{entity_name}: entity_category mismatch - " + f"expected {expected_category}, got {entity.entity_category}" + ) + + # Verify internal entities are NOT visible + for entity_name in internal_entities: + assert entity_name not in entity_map, ( + f"Entity '{entity_name}' found in API response - " + f"internal entities should not be exposed via API" + ) + + # Subscribe to states to verify has_state flag works + states: dict[int, EntityState] = {} + state_received = asyncio.Event() + + def on_state(state: EntityState) -> None: + states[state.key] = state + state_received.set() + + client.subscribe_states(on_state) + + # Wait for at least one state + try: + await asyncio.wait_for(state_received.wait(), timeout=5.0) + except asyncio.TimeoutError: + pytest.fail("No states received within 5 seconds") + + # Verify we received states (which means has_state flag is working) + assert len(states) > 0, "No states received - has_state flag may not be working" From 5640a9fe730abb0cb5c109eaa49fefaf6102f3a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Jun 2025 22:28:15 -0500 Subject: [PATCH 13/27] Optimize memory usage by lazy-allocating raw callbacks in sensors (#9077) --- esphome/components/sensor/sensor.cpp | 9 +++++++-- esphome/components/sensor/sensor.h | 5 +++-- esphome/components/text_sensor/text_sensor.cpp | 9 +++++++-- esphome/components/text_sensor/text_sensor.h | 8 ++++++-- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 251ef47ecc..3be0df9963 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -38,7 +38,9 @@ StateClass Sensor::get_state_class() { void Sensor::publish_state(float state) { this->raw_state = state; - this->raw_callback_.call(state); + if (this->raw_callback_) { + this->raw_callback_->call(state); + } ESP_LOGV(TAG, "'%s': Received new state %f", this->name_.c_str(), state); @@ -51,7 +53,10 @@ void Sensor::publish_state(float state) { void Sensor::add_on_state_callback(std::function &&callback) { this->callback_.add(std::move(callback)); } void Sensor::add_on_raw_state_callback(std::function &&callback) { - this->raw_callback_.add(std::move(callback)); + if (!this->raw_callback_) { + this->raw_callback_ = std::make_unique>(); + } + this->raw_callback_->add(std::move(callback)); } void Sensor::add_filter(Filter *filter) { diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index ac61548a55..456e876497 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -7,6 +7,7 @@ #include "esphome/components/sensor/filter.h" #include +#include namespace esphome { namespace sensor { @@ -149,8 +150,8 @@ class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBa void internal_send_state_to_frontend(float state); protected: - CallbackManager raw_callback_; ///< Storage for raw state callbacks. - CallbackManager callback_; ///< Storage for filtered state callbacks. + std::unique_ptr> raw_callback_; ///< Storage for raw state callbacks (lazy allocated). + CallbackManager callback_; ///< Storage for filtered state callbacks. Filter *filter_list_{nullptr}; ///< Store all active filters. diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 1138ada281..91cb320782 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -8,7 +8,9 @@ static const char *const TAG = "text_sensor"; void TextSensor::publish_state(const std::string &state) { this->raw_state = state; - this->raw_callback_.call(state); + if (this->raw_callback_) { + this->raw_callback_->call(state); + } ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), state.c_str()); @@ -53,7 +55,10 @@ void TextSensor::add_on_state_callback(std::function callback this->callback_.add(std::move(callback)); } void TextSensor::add_on_raw_state_callback(std::function callback) { - this->raw_callback_.add(std::move(callback)); + if (!this->raw_callback_) { + this->raw_callback_ = std::make_unique>(); + } + this->raw_callback_->add(std::move(callback)); } std::string TextSensor::get_state() const { return this->state; } diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 5e45968ef4..b27145aa18 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -6,6 +6,7 @@ #include "esphome/components/text_sensor/filter.h" #include +#include namespace esphome { namespace text_sensor { @@ -33,6 +34,8 @@ namespace text_sensor { class TextSensor : public EntityBase, public EntityBase_DeviceClass { public: + TextSensor() = default; + /// Getter-syntax for .state. std::string get_state() const; /// Getter-syntax for .raw_state @@ -70,8 +73,9 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { void internal_send_state_to_frontend(const std::string &state); protected: - CallbackManager raw_callback_; ///< Storage for raw state callbacks. - CallbackManager callback_; ///< Storage for filtered state callbacks. + std::unique_ptr> + raw_callback_; ///< Storage for raw state callbacks (lazy allocated). + CallbackManager callback_; ///< Storage for filtered state callbacks. Filter *filter_list_{nullptr}; ///< Store all active filters. }; From 1719a2e08b9ed0b5dcf72504cf00e7e64d7c75b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Jun 2025 22:46:02 -0500 Subject: [PATCH 14/27] Fix API message encoding to return actual size instead of calculated size (#9073) --- esphome/components/api/api_connection.cpp | 32 +++++++++++++++++------ tests/integration/conftest.py | 15 +++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d09b1107d2..ca6e2a2d56 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -248,25 +248,41 @@ void APIConnection::on_disconnect_response(const DisconnectResponse &value) { uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint16_t message_type, APIConnection *conn, uint32_t remaining_size, bool is_single) { // Calculate size - uint32_t size = 0; - msg.calculate_size(size); + uint32_t calculated_size = 0; + msg.calculate_size(calculated_size); + + // Cache frame sizes to avoid repeated virtual calls + const uint8_t header_padding = conn->helper_->frame_header_padding(); + const uint8_t footer_size = conn->helper_->frame_footer_size(); // Calculate total size with padding for buffer allocation - uint16_t total_size = - static_cast(size) + conn->helper_->frame_header_padding() + conn->helper_->frame_footer_size(); + size_t total_calculated_size = calculated_size + header_padding + footer_size; // Check if it fits - if (total_size > remaining_size) { + if (total_calculated_size > remaining_size) { return 0; // Doesn't fit } // Allocate buffer space - pass payload size, allocation functions add header/footer space - ProtoWriteBuffer buffer = - is_single ? conn->allocate_single_message_buffer(size) : conn->allocate_batch_message_buffer(size); + ProtoWriteBuffer buffer = is_single ? conn->allocate_single_message_buffer(calculated_size) + : conn->allocate_batch_message_buffer(calculated_size); + + // Get buffer size after allocation (which includes header padding) + std::vector &shared_buf = conn->parent_->get_shared_buffer_ref(); + size_t size_before_encode = shared_buf.size(); // Encode directly into buffer msg.encode(buffer); - return total_size; + + // Calculate actual encoded size (not including header that was already added) + size_t actual_payload_size = shared_buf.size() - size_before_encode; + + // Return actual total size (header + actual payload + footer) + size_t actual_total_size = header_padding + actual_payload_size + footer_size; + + // Verify that calculate_size() returned the correct value + assert(calculated_size == actual_payload_size); + return static_cast(actual_total_size); } #ifdef USE_BINARY_SENSOR diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 4c798c6b72..4eb1584c27 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -119,6 +119,21 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s # Add port configuration after api: content = content.replace("api:", f"api:\n port: {unused_tcp_port}") + # Add debug build flags for integration tests to enable assertions + if "esphome:" in content: + # Check if platformio_options already exists + if "platformio_options:" not in content: + # Add platformio_options with debug flags after esphome: + content = content.replace( + "esphome:", + "esphome:\n" + " # Enable assertions for integration tests\n" + " platformio_options:\n" + " build_flags:\n" + ' - "-DDEBUG" # Enable assert() statements\n' + ' - "-g" # Add debug symbols', + ) + return content From 5cdcf2415dc12ae1e693fafe89abf66c3f8e71be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Jun 2025 22:46:40 -0500 Subject: [PATCH 15/27] Optimize Application area_ from std::string to const char* (#9085) --- esphome/components/mqtt/mqtt_component.cpp | 2 +- esphome/core/application.h | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 456ae25e65..eee5644c9d 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -153,7 +153,7 @@ bool MQTTComponent::send_discovery_() { if (node_friendly_name.empty()) { node_friendly_name = node_name; } - const std::string &node_area = App.get_area(); + std::string node_area = App.get_area(); JsonObject device_info = root.createNestedObject(MQTT_DEVICE); const auto mac = get_mac_address(); diff --git a/esphome/core/application.h b/esphome/core/application.h index d9ef4fe036..f04ea05d8e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -87,8 +87,8 @@ static const uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for quick class Application { public: - void pre_setup(const std::string &name, const std::string &friendly_name, const std::string &area, - const char *comment, const char *compilation_time, bool name_add_mac_suffix) { + void pre_setup(const std::string &name, const std::string &friendly_name, const char *area, const char *comment, + const char *compilation_time, bool name_add_mac_suffix) { arch_init(); this->name_add_mac_suffix_ = name_add_mac_suffix; if (name_add_mac_suffix) { @@ -285,7 +285,7 @@ class Application { const std::string &get_friendly_name() const { return this->friendly_name_; } /// Get the area of this Application set by pre_setup(). - const std::string &get_area() const { return this->area_; } + std::string get_area() const { return this->area_ == nullptr ? "" : this->area_; } /// Get the comment of this Application set by pre_setup(). std::string get_comment() const { return this->comment_; } @@ -646,7 +646,7 @@ class Application { std::string name_; std::string friendly_name_; - std::string area_; + const char *area_{nullptr}; const char *comment_{nullptr}; const char *compilation_time_{nullptr}; bool name_add_mac_suffix_; From 6f4e76c8f30801ff1e153afa98d0b15e220a95bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Jun 2025 23:45:41 -0500 Subject: [PATCH 16/27] Fix unbound BLE event queue growth and reduce memory usage (#9052) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 4 +- .../bluetooth_proxy/bluetooth_proxy.h | 2 +- esphome/components/esp32_ble/ble.cpp | 119 +++++--- esphome/components/esp32_ble/ble.h | 24 +- esphome/components/esp32_ble/ble_event.h | 276 +++++++++++++----- .../components/esp32_ble/ble_scan_result.h | 24 ++ esphome/components/esp32_ble/queue.h | 11 + .../components/esp32_ble_tracker/__init__.py | 1 + .../esp32_ble_tracker/esp32_ble_tracker.cpp | 91 +++--- .../esp32_ble_tracker/esp32_ble_tracker.h | 20 +- esphome/components/logger/logger.cpp | 17 +- esphome/components/logger/logger.h | 3 +- 12 files changed, 397 insertions(+), 195 deletions(-) create mode 100644 esphome/components/esp32_ble/ble_scan_result.h diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 7aeb818306..fbe2a3e67c 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -58,7 +58,7 @@ static std::vector &get_batch_buffer() { return batch_buffer; } -bool BluetoothProxy::parse_devices(esp_ble_gap_cb_param_t::ble_scan_result_evt_param *advertisements, size_t count) { +bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) { if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr || !this->raw_advertisements_) return false; @@ -73,7 +73,7 @@ bool BluetoothProxy::parse_devices(esp_ble_gap_cb_param_t::ble_scan_result_evt_p // Add new advertisements to the batch buffer for (size_t i = 0; i < count; i++) { - auto &result = advertisements[i]; + auto &result = scan_results[i]; uint8_t length = result.adv_data_len + result.scan_rsp_len; batch_buffer.emplace_back(); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index f75e73e796..16db0a0a11 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -52,7 +52,7 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com public: BluetoothProxy(); bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; - bool parse_devices(esp_ble_gap_cb_param_t::ble_scan_result_evt_param *advertisements, size_t count) override; + bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; void dump_config() override; void setup() override; void loop() override; diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 824c2b9dbc..ed74d59ef2 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -23,6 +23,9 @@ namespace esp32_ble { static const char *const TAG = "esp32_ble"; +// Maximum size of the BLE event queue +static constexpr size_t MAX_BLE_QUEUE_SIZE = SCAN_RESULT_BUFFER_SIZE * 2; + static RAMAllocator EVENT_ALLOCATOR( // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) RAMAllocator::ALLOW_FAILURE | RAMAllocator::ALLOC_INTERNAL); @@ -304,20 +307,52 @@ void ESP32BLE::loop() { BLEEvent *ble_event = this->ble_events_.pop(); while (ble_event != nullptr) { switch (ble_event->type_) { - case BLEEvent::GATTS: - this->real_gatts_event_handler_(ble_event->event_.gatts.gatts_event, ble_event->event_.gatts.gatts_if, - &ble_event->event_.gatts.gatts_param); + case BLEEvent::GATTS: { + esp_gatts_cb_event_t event = ble_event->event_.gatts.gatts_event; + esp_gatt_if_t gatts_if = ble_event->event_.gatts.gatts_if; + esp_ble_gatts_cb_param_t *param = ble_event->event_.gatts.gatts_param; + ESP_LOGV(TAG, "gatts_event [esp_gatt_if: %d] - %d", gatts_if, event); + for (auto *gatts_handler : this->gatts_event_handlers_) { + gatts_handler->gatts_event_handler(event, gatts_if, param); + } break; - case BLEEvent::GATTC: - this->real_gattc_event_handler_(ble_event->event_.gattc.gattc_event, ble_event->event_.gattc.gattc_if, - &ble_event->event_.gattc.gattc_param); + } + case BLEEvent::GATTC: { + esp_gattc_cb_event_t event = ble_event->event_.gattc.gattc_event; + esp_gatt_if_t gattc_if = ble_event->event_.gattc.gattc_if; + esp_ble_gattc_cb_param_t *param = ble_event->event_.gattc.gattc_param; + ESP_LOGV(TAG, "gattc_event [esp_gatt_if: %d] - %d", gattc_if, event); + for (auto *gattc_handler : this->gattc_event_handlers_) { + gattc_handler->gattc_event_handler(event, gattc_if, param); + } break; - case BLEEvent::GAP: - this->real_gap_event_handler_(ble_event->event_.gap.gap_event, &ble_event->event_.gap.gap_param); + } + case BLEEvent::GAP: { + esp_gap_ble_cb_event_t gap_event = ble_event->event_.gap.gap_event; + if (gap_event == ESP_GAP_BLE_SCAN_RESULT_EVT) { + // Use the new scan event handler - no memcpy! + for (auto *scan_handler : this->gap_scan_event_handlers_) { + scan_handler->gap_scan_event_handler(ble_event->scan_result()); + } + } else if (gap_event == ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT || + gap_event == ESP_GAP_BLE_SCAN_START_COMPLETE_EVT || + gap_event == ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT) { + // All three scan complete events have the same structure with just status + // The scan_complete struct matches ESP-IDF's layout exactly, so this reinterpret_cast is safe + // This is verified at compile-time by static_assert checks in ble_event.h + // The struct already contains our copy of the status (copied in BLEEvent constructor) + ESP_LOGV(TAG, "gap_event_handler - %d", gap_event); + for (auto *gap_handler : this->gap_event_handlers_) { + gap_handler->gap_event_handler( + gap_event, reinterpret_cast(&ble_event->event_.gap.scan_complete)); + } + } break; + } default: break; } + // Destructor will clean up external allocations for GATTC/GATTS ble_event->~BLEEvent(); EVENT_ALLOCATOR.deallocate(ble_event, 1); ble_event = this->ble_events_.pop(); @@ -327,59 +362,55 @@ void ESP32BLE::loop() { } } -void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { +template void enqueue_ble_event(Args... args) { + if (global_ble->ble_events_.size() >= MAX_BLE_QUEUE_SIZE) { + ESP_LOGD(TAG, "Event queue full (%zu), dropping event", MAX_BLE_QUEUE_SIZE); + 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 return; } - new (new_event) BLEEvent(event, param); + new (new_event) BLEEvent(args...); global_ble->ble_events_.push(new_event); } // NOLINT(clang-analyzer-unix.Malloc) -void ESP32BLE::real_gap_event_handler_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { - ESP_LOGV(TAG, "(BLE) gap_event_handler - %d", event); - for (auto *gap_handler : this->gap_event_handlers_) { - gap_handler->gap_event_handler(event, param); +// Explicit template instantiations for the friend function +template void enqueue_ble_event(esp_gap_ble_cb_event_t, esp_ble_gap_cb_param_t *); +template void enqueue_ble_event(esp_gatts_cb_event_t, esp_gatt_if_t, esp_ble_gatts_cb_param_t *); +template void enqueue_ble_event(esp_gattc_cb_event_t, esp_gatt_if_t, esp_ble_gattc_cb_param_t *); + +void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + // Only queue the 4 GAP events we actually handle + case ESP_GAP_BLE_SCAN_RESULT_EVT: + case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: + case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT: + case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT: + enqueue_ble_event(event, param); + return; + + // Ignore these GAP events as they are not relevant for our use case + case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: + case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: + return; + + default: + break; } + ESP_LOGW(TAG, "Ignoring unexpected GAP event type: %d", event); } void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) { - 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 - return; - } - new (new_event) BLEEvent(event, gatts_if, param); - global_ble->ble_events_.push(new_event); -} // NOLINT(clang-analyzer-unix.Malloc) - -void ESP32BLE::real_gatts_event_handler_(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, - esp_ble_gatts_cb_param_t *param) { - ESP_LOGV(TAG, "(BLE) gatts_event [esp_gatt_if: %d] - %d", gatts_if, event); - for (auto *gatts_handler : this->gatts_event_handlers_) { - gatts_handler->gatts_event_handler(event, gatts_if, param); - } + enqueue_ble_event(event, gatts_if, param); } void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { - 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 - return; - } - new (new_event) BLEEvent(event, gattc_if, param); - global_ble->ble_events_.push(new_event); -} // NOLINT(clang-analyzer-unix.Malloc) - -void ESP32BLE::real_gattc_event_handler_(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) { - ESP_LOGV(TAG, "(BLE) gattc_event [esp_gatt_if: %d] - %d", gattc_if, event); - for (auto *gattc_handler : this->gattc_event_handlers_) { - gattc_handler->gattc_event_handler(event, gattc_if, param); - } + enqueue_ble_event(event, gattc_if, param); } float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 13ec3b6dd9..6508db1a00 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -2,6 +2,7 @@ #include "ble_advertising.h" #include "ble_uuid.h" +#include "ble_scan_result.h" #include @@ -22,6 +23,13 @@ namespace esphome { namespace esp32_ble { +// Maximum number of BLE scan results to buffer +#ifdef USE_PSRAM +static constexpr uint8_t SCAN_RESULT_BUFFER_SIZE = 32; +#else +static constexpr uint8_t SCAN_RESULT_BUFFER_SIZE = 20; +#endif + uint64_t ble_addr_to_uint64(const esp_bd_addr_t address); // NOLINTNEXTLINE(modernize-use-using) @@ -57,6 +65,11 @@ class GAPEventHandler { virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; }; +class GAPScanEventHandler { + public: + virtual void gap_scan_event_handler(const BLEScanResult &scan_result) = 0; +}; + class GATTcEventHandler { public: virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, @@ -101,6 +114,9 @@ class ESP32BLE : public Component { void advertising_register_raw_advertisement_callback(std::function &&callback); void register_gap_event_handler(GAPEventHandler *handler) { this->gap_event_handlers_.push_back(handler); } + void register_gap_scan_event_handler(GAPScanEventHandler *handler) { + this->gap_scan_event_handlers_.push_back(handler); + } void register_gattc_event_handler(GATTcEventHandler *handler) { this->gattc_event_handlers_.push_back(handler); } void register_gatts_event_handler(GATTsEventHandler *handler) { this->gatts_event_handlers_.push_back(handler); } void register_ble_status_event_handler(BLEStatusEventHandler *handler) { @@ -113,16 +129,16 @@ class ESP32BLE : public Component { static void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param); static void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); - void real_gatts_event_handler_(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param); - void real_gattc_event_handler_(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param); - void real_gap_event_handler_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); - bool ble_setup_(); bool ble_dismantle_(); bool ble_pre_setup_(); void advertising_init_(); + private: + template friend void enqueue_ble_event(Args... args); + std::vector gap_event_handlers_; + std::vector gap_scan_event_handlers_; std::vector gattc_event_handlers_; std::vector gatts_event_handlers_; std::vector ble_status_event_handlers_; diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index 1cf63b2fab..f51095effd 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -2,92 +2,232 @@ #ifdef USE_ESP32 +#include // for offsetof #include #include #include #include +#include "ble_scan_result.h" + namespace esphome { namespace esp32_ble { + +// Compile-time verification that ESP-IDF scan complete events only contain a status field +// This ensures our reinterpret_cast in ble.cpp is safe +static_assert(sizeof(esp_ble_gap_cb_param_t::ble_scan_param_cmpl_evt_param) == sizeof(esp_bt_status_t), + "ESP-IDF scan_param_cmpl structure has unexpected size"); +static_assert(sizeof(esp_ble_gap_cb_param_t::ble_scan_start_cmpl_evt_param) == sizeof(esp_bt_status_t), + "ESP-IDF scan_start_cmpl structure has unexpected size"); +static_assert(sizeof(esp_ble_gap_cb_param_t::ble_scan_stop_cmpl_evt_param) == sizeof(esp_bt_status_t), + "ESP-IDF scan_stop_cmpl structure has unexpected size"); + +// Verify the status field is at offset 0 (first member) +static_assert(offsetof(esp_ble_gap_cb_param_t, scan_param_cmpl.status) == + offsetof(esp_ble_gap_cb_param_t, scan_param_cmpl), + "status must be first member of scan_param_cmpl"); +static_assert(offsetof(esp_ble_gap_cb_param_t, scan_start_cmpl.status) == + offsetof(esp_ble_gap_cb_param_t, scan_start_cmpl), + "status must be first member of scan_start_cmpl"); +static_assert(offsetof(esp_ble_gap_cb_param_t, scan_stop_cmpl.status) == + offsetof(esp_ble_gap_cb_param_t, scan_stop_cmpl), + "status must be first member of scan_stop_cmpl"); + // Received GAP, GATTC and GATTS events are only queued, and get processed in the main loop(). -// This class stores each event in a single type. +// This class stores each event with minimal memory usage. +// GAP events (99% of traffic) don't have the vector overhead. +// GATTC/GATTS events use heap allocation for their param and data. +// +// Event flow: +// 1. ESP-IDF BLE stack calls our static handlers in the BLE task context +// 2. The handlers create a BLEEvent instance, copying only the data we need +// 3. The event is pushed to a thread-safe queue +// 4. In the main loop(), events are popped from the queue and processed +// 5. The event destructor cleans up any external allocations +// +// Thread safety: +// - GAP events: We copy only the fields we need directly into the union +// - 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. class BLEEvent { public: - BLEEvent(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) { - this->event_.gap.gap_event = e; - memcpy(&this->event_.gap.gap_param, p, sizeof(esp_ble_gap_cb_param_t)); - this->type_ = GAP; - }; - - BLEEvent(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; - memcpy(&this->event_.gattc.gattc_param, p, sizeof(esp_ble_gattc_cb_param_t)); - // Need to also make a copy of relevant event data. - switch (e) { - case ESP_GATTC_NOTIFY_EVT: - this->data.assign(p->notify.value, p->notify.value + p->notify.value_len); - this->event_.gattc.gattc_param.notify.value = this->data.data(); - break; - case ESP_GATTC_READ_CHAR_EVT: - case ESP_GATTC_READ_DESCR_EVT: - this->data.assign(p->read.value, p->read.value + p->read.value_len); - this->event_.gattc.gattc_param.read.value = this->data.data(); - break; - default: - break; - } - this->type_ = GATTC; - }; - - BLEEvent(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; - memcpy(&this->event_.gatts.gatts_param, p, sizeof(esp_ble_gatts_cb_param_t)); - // Need to also make a copy of relevant event data. - switch (e) { - case ESP_GATTS_WRITE_EVT: - this->data.assign(p->write.value, p->write.value + p->write.len); - this->event_.gatts.gatts_param.write.value = this->data.data(); - break; - default: - break; - } - this->type_ = GATTS; - }; - - union { - // NOLINTNEXTLINE(readability-identifier-naming) - struct gap_event { - esp_gap_ble_cb_event_t gap_event; - esp_ble_gap_cb_param_t gap_param; - } gap; - - // NOLINTNEXTLINE(readability-identifier-naming) - struct gattc_event { - esp_gattc_cb_event_t gattc_event; - esp_gatt_if_t gattc_if; - esp_ble_gattc_cb_param_t gattc_param; - } gattc; - - // NOLINTNEXTLINE(readability-identifier-naming) - struct gatts_event { - esp_gatts_cb_event_t gatts_event; - esp_gatt_if_t gatts_if; - esp_ble_gatts_cb_param_t gatts_param; - } gatts; - } event_; - - std::vector data{}; // NOLINTNEXTLINE(readability-identifier-naming) enum ble_event_t : uint8_t { GAP, GATTC, GATTS, - } type_; + }; + + // 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; + } + } + + // Constructor for GATTC events - uses heap allocation + // Creates a copy of the param struct since the original is only valid during the callback + 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; + } + } + + // Constructor for GATTS events - uses heap allocation + // Creates a copy of the param struct since the original is only valid during the callback + 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; + } + } + + // 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; + } + } + + // Disable copy to prevent double-delete + BLEEvent(const BLEEvent &) = delete; + BLEEvent &operator=(const BLEEvent &) = delete; + + union { + // NOLINTNEXTLINE(readability-identifier-naming) + struct gap_event { + esp_gap_ble_cb_event_t gap_event; + union { + BLEScanResult scan_result; // 73 bytes + // This matches ESP-IDF's scan complete event structures + // All three (scan_param_cmpl, scan_start_cmpl, scan_stop_cmpl) have identical layout + struct { + esp_bt_status_t status; + } scan_complete; // 1 byte + }; + } gap; // 80 bytes total + + // NOLINTNEXTLINE(readability-identifier-naming) + struct gattc_event { + esp_gattc_cb_event_t gattc_event; + esp_gatt_if_t gattc_if; + esp_ble_gattc_cb_param_t *gattc_param; // Heap-allocated + std::vector *data; // Heap-allocated + } gattc; // 16 bytes (pointers only) + + // NOLINTNEXTLINE(readability-identifier-naming) + struct gatts_event { + esp_gatts_cb_event_t gatts_event; + esp_gatt_if_t gatts_if; + esp_ble_gatts_cb_param_t *gatts_param; // Heap-allocated + std::vector *data; // Heap-allocated + } gatts; // 16 bytes (pointers only) + } event_; // 80 bytes + + ble_event_t type_; + + // Helper methods to access event data + ble_event_t type() const { return type_; } + 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; } }; +// BLEEvent total size: 84 bytes (80 byte union + 1 byte type + 3 bytes padding) + } // namespace esp32_ble } // namespace esphome diff --git a/esphome/components/esp32_ble/ble_scan_result.h b/esphome/components/esp32_ble/ble_scan_result.h new file mode 100644 index 0000000000..49b0d5523d --- /dev/null +++ b/esphome/components/esp32_ble/ble_scan_result.h @@ -0,0 +1,24 @@ +#pragma once + +#ifdef USE_ESP32 + +#include + +namespace esphome { +namespace esp32_ble { + +// Structure for BLE scan results - only fields we actually use +struct __attribute__((packed)) BLEScanResult { + esp_bd_addr_t bda; + uint8_t ble_addr_type; + int8_t rssi; + uint8_t ble_adv[ESP_BLE_ADV_DATA_LEN_MAX + ESP_BLE_SCAN_RSP_DATA_LEN_MAX]; + uint8_t adv_data_len; + uint8_t scan_rsp_len; + uint8_t search_evt; +}; // ~73 bytes vs ~400 bytes for full esp_ble_gap_cb_param_t + +} // namespace esp32_ble +} // namespace esphome + +#endif diff --git a/esphome/components/esp32_ble/queue.h b/esphome/components/esp32_ble/queue.h index c98477e121..f69878bf6e 100644 --- a/esphome/components/esp32_ble/queue.h +++ b/esphome/components/esp32_ble/queue.h @@ -45,6 +45,17 @@ template class Queue { return element; } + size_t size() const { + // Lock-free size check. While std::queue::size() is not thread-safe, we intentionally + // avoid locking here to prevent blocking the BLE callback thread. The size is only + // used to decide whether to drop incoming events when the queue is near capacity. + // With a queue limit of 40-64 events and normal processing, dropping events should + // be extremely rare. When it does approach capacity, being off by 1-2 events is + // acceptable to avoid blocking the BLE stack's time-sensitive callbacks. + // Trade-off: We prefer occasional dropped events over potential BLE stack delays. + return q_.size(); + } + protected: std::queue q_; SemaphoreHandle_t m_; diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 61eed1c029..2242d709a4 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -268,6 +268,7 @@ async def to_code(config): parent = await cg.get_variable(config[esp32_ble.CONF_BLE_ID]) cg.add(parent.register_gap_event_handler(var)) + cg.add(parent.register_gap_scan_event_handler(var)) cg.add(parent.register_gattc_event_handler(var)) cg.add(parent.register_ble_status_event_handler(var)) cg.add(var.set_parent(parent)) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 6d60f1638c..ab3efc3ad3 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -50,9 +50,8 @@ void ESP32BLETracker::setup() { ESP_LOGE(TAG, "BLE Tracker was marked failed by ESP32BLE"); return; } - ExternalRAMAllocator allocator( - ExternalRAMAllocator::ALLOW_FAILURE); - this->scan_result_buffer_ = allocator.allocate(ESP32BLETracker::SCAN_RESULT_BUFFER_SIZE); + RAMAllocator allocator; + this->scan_result_buffer_ = allocator.allocate(SCAN_RESULT_BUFFER_SIZE); if (this->scan_result_buffer_ == nullptr) { ESP_LOGE(TAG, "Could not allocate buffer for BLE Tracker!"); @@ -124,7 +123,7 @@ void ESP32BLETracker::loop() { this->scan_result_index_ && // if it looks like we have a scan result we will take the lock xSemaphoreTake(this->scan_result_lock_, 0)) { uint32_t index = this->scan_result_index_; - if (index >= ESP32BLETracker::SCAN_RESULT_BUFFER_SIZE) { + if (index >= SCAN_RESULT_BUFFER_SIZE) { ESP_LOGW(TAG, "Too many BLE events to process. Some devices may not show up."); } @@ -370,9 +369,6 @@ void ESP32BLETracker::recalculate_advertisement_parser_types() { void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { switch (event) { - case ESP_GAP_BLE_SCAN_RESULT_EVT: - this->gap_scan_result_(param->scan_rst); - break; case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: this->gap_scan_set_param_complete_(param->scan_param_cmpl); break; @@ -385,11 +381,42 @@ void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_ga default: break; } + // Forward all events to clients (scan results are handled separately via gap_scan_event_handler) for (auto *client : this->clients_) { client->gap_event_handler(event, param); } } +void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { + ESP_LOGV(TAG, "gap_scan_result - event %d", scan_result.search_evt); + + if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { + if (xSemaphoreTake(this->scan_result_lock_, 0)) { + if (this->scan_result_index_ < SCAN_RESULT_BUFFER_SIZE) { + // Store BLEScanResult directly in our buffer + this->scan_result_buffer_[this->scan_result_index_++] = scan_result; + } + xSemaphoreGive(this->scan_result_lock_); + } + } else if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { + // Scan finished on its own + if (this->scanner_state_ != ScannerState::RUNNING) { + if (this->scanner_state_ == ScannerState::STOPPING) { + ESP_LOGE(TAG, "Scan was not running when scan completed."); + } else if (this->scanner_state_ == ScannerState::STARTING) { + ESP_LOGE(TAG, "Scan was not started when scan completed."); + } else if (this->scanner_state_ == ScannerState::FAILED) { + ESP_LOGE(TAG, "Scan was in failed state when scan completed."); + } else if (this->scanner_state_ == ScannerState::IDLE) { + ESP_LOGE(TAG, "Scan was idle when scan completed."); + } else if (this->scanner_state_ == ScannerState::STOPPED) { + ESP_LOGE(TAG, "Scan was stopped when scan completed."); + } + } + this->set_scanner_state_(ScannerState::STOPPED); + } +} + void ESP32BLETracker::gap_scan_set_param_complete_(const esp_ble_gap_cb_param_t::ble_scan_param_cmpl_evt_param ¶m) { ESP_LOGV(TAG, "gap_scan_set_param_complete - status %d", param.status); if (param.status == ESP_BT_STATUS_DONE) { @@ -444,34 +471,6 @@ void ESP32BLETracker::gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_ this->set_scanner_state_(ScannerState::STOPPED); } -void ESP32BLETracker::gap_scan_result_(const esp_ble_gap_cb_param_t::ble_scan_result_evt_param ¶m) { - ESP_LOGV(TAG, "gap_scan_result - event %d", param.search_evt); - if (param.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { - if (xSemaphoreTake(this->scan_result_lock_, 0)) { - if (this->scan_result_index_ < ESP32BLETracker::SCAN_RESULT_BUFFER_SIZE) { - this->scan_result_buffer_[this->scan_result_index_++] = param; - } - xSemaphoreGive(this->scan_result_lock_); - } - } else if (param.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { - // Scan finished on its own - if (this->scanner_state_ != ScannerState::RUNNING) { - if (this->scanner_state_ == ScannerState::STOPPING) { - ESP_LOGE(TAG, "Scan was not running when scan completed."); - } else if (this->scanner_state_ == ScannerState::STARTING) { - ESP_LOGE(TAG, "Scan was not started when scan completed."); - } else if (this->scanner_state_ == ScannerState::FAILED) { - ESP_LOGE(TAG, "Scan was in failed state when scan completed."); - } else if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGE(TAG, "Scan was idle when scan completed."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan was stopped when scan completed."); - } - } - this->set_scanner_state_(ScannerState::STOPPED); - } -} - void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { for (auto *client : this->clients_) { @@ -494,13 +493,15 @@ optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData return ESPBLEiBeacon(data.data.data()); } -void ESPBTDevice::parse_scan_rst(const esp_ble_gap_cb_param_t::ble_scan_result_evt_param ¶m) { - this->scan_result_ = param; +void ESPBTDevice::parse_scan_rst(const BLEScanResult &scan_result) { for (uint8_t i = 0; i < ESP_BD_ADDR_LEN; i++) - this->address_[i] = param.bda[i]; - this->address_type_ = param.ble_addr_type; - this->rssi_ = param.rssi; - this->parse_adv_(param); + this->address_[i] = scan_result.bda[i]; + this->address_type_ = static_cast(scan_result.ble_addr_type); + this->rssi_ = scan_result.rssi; + + // Parse advertisement data directly + uint8_t total_len = scan_result.adv_data_len + scan_result.scan_rsp_len; + this->parse_adv_(scan_result.ble_adv, total_len); #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE ESP_LOGVV(TAG, "Parse Result:"); @@ -558,13 +559,13 @@ void ESPBTDevice::parse_scan_rst(const esp_ble_gap_cb_param_t::ble_scan_result_e ESP_LOGVV(TAG, " Data: %s", format_hex_pretty(data.data).c_str()); } - ESP_LOGVV(TAG, " Adv data: %s", format_hex_pretty(param.ble_adv, param.adv_data_len + param.scan_rsp_len).c_str()); + ESP_LOGVV(TAG, " Adv data: %s", + format_hex_pretty(scan_result.ble_adv, scan_result.adv_data_len + scan_result.scan_rsp_len).c_str()); #endif } -void ESPBTDevice::parse_adv_(const esp_ble_gap_cb_param_t::ble_scan_result_evt_param ¶m) { + +void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) { size_t offset = 0; - const uint8_t *payload = param.ble_adv; - uint8_t len = param.adv_data_len + param.scan_rsp_len; while (offset + 2 < len) { const uint8_t field_length = payload[offset++]; // First byte is length of adv record diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index eea73a7d26..33c0caaa87 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -62,7 +62,7 @@ class ESPBLEiBeacon { class ESPBTDevice { public: - void parse_scan_rst(const esp_ble_gap_cb_param_t::ble_scan_result_evt_param ¶m); + void parse_scan_rst(const BLEScanResult &scan_result); std::string address_str() const; @@ -84,8 +84,6 @@ class ESPBTDevice { const std::vector &get_service_datas() const { return service_datas_; } - const esp_ble_gap_cb_param_t::ble_scan_result_evt_param &get_scan_result() const { return scan_result_; } - bool resolve_irk(const uint8_t *irk) const; optional get_ibeacon() const { @@ -98,7 +96,7 @@ class ESPBTDevice { } protected: - void parse_adv_(const esp_ble_gap_cb_param_t::ble_scan_result_evt_param ¶m); + void parse_adv_(const uint8_t *payload, uint8_t len); esp_bd_addr_t address_{ 0, @@ -112,7 +110,6 @@ class ESPBTDevice { std::vector service_uuids_{}; std::vector manufacturer_datas_{}; std::vector service_datas_{}; - esp_ble_gap_cb_param_t::ble_scan_result_evt_param scan_result_{}; }; class ESP32BLETracker; @@ -121,9 +118,7 @@ class ESPBTDeviceListener { public: virtual void on_scan_end() {} virtual bool parse_device(const ESPBTDevice &device) = 0; - virtual bool parse_devices(esp_ble_gap_cb_param_t::ble_scan_result_evt_param *advertisements, size_t count) { - return false; - }; + virtual bool parse_devices(const BLEScanResult *scan_results, size_t count) { return false; }; virtual AdvertisementParserType get_advertisement_parser_type() { return AdvertisementParserType::PARSED_ADVERTISEMENTS; }; @@ -210,6 +205,7 @@ class ESPBTClient : public ESPBTDeviceListener { class ESP32BLETracker : public Component, public GAPEventHandler, + public GAPScanEventHandler, public GATTcEventHandler, public BLEStatusEventHandler, public Parented { @@ -240,6 +236,7 @@ class ESP32BLETracker : public Component, void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override; void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + void gap_scan_event_handler(const BLEScanResult &scan_result) override; void ble_before_disabled_event_handler() override; void add_scanner_state_callback(std::function &&callback) { @@ -287,12 +284,7 @@ class ESP32BLETracker : public Component, bool parse_advertisements_{false}; SemaphoreHandle_t scan_result_lock_; size_t scan_result_index_{0}; -#ifdef USE_PSRAM - const static u_int8_t SCAN_RESULT_BUFFER_SIZE = 32; -#else - const static u_int8_t SCAN_RESULT_BUFFER_SIZE = 20; -#endif // USE_PSRAM - esp_ble_gap_cb_param_t::ble_scan_result_evt_param *scan_result_buffer_; + BLEScanResult *scan_result_buffer_; esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; int connecting_{0}; diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 59a3398ce8..28a66b23b7 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -116,7 +116,7 @@ void Logger::log_vprintf_(int level, const char *tag, int line, const __FlashStr if (this->baud_rate_ > 0) { this->write_msg_(this->tx_buffer_ + msg_start); } - this->call_log_callbacks_(level, tag, this->tx_buffer_ + msg_start); + this->log_callback_.call(level, tag, this->tx_buffer_ + msg_start); global_recursion_guard_ = false; } @@ -129,19 +129,6 @@ inline int Logger::level_for(const char *tag) { return this->current_level_; } -void HOT Logger::call_log_callbacks_(int level, const char *tag, const char *msg) { -#ifdef USE_ESP32 - // Suppress network-logging if memory constrained - // In some configurations (eg BLE enabled) there may be some transient - // memory exhaustion, and trying to log when OOM can lead to a crash. Skipping - // here usually allows the stack to recover instead. - // See issue #1234 for analysis. - if (xPortGetFreeHeapSize() < 2048) - return; -#endif - this->log_callback_.call(level, tag, msg); -} - Logger::Logger(uint32_t baud_rate, size_t tx_buffer_size) : baud_rate_(baud_rate), tx_buffer_size_(tx_buffer_size) { // add 1 to buffer size for null terminator this->tx_buffer_ = new char[this->tx_buffer_size_ + 1]; // NOLINT @@ -189,7 +176,7 @@ void Logger::loop() { this->tx_buffer_size_); this->write_footer_to_buffer_(this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_); this->tx_buffer_[this->tx_buffer_at_] = '\0'; - this->call_log_callbacks_(message->level, message->tag, this->tx_buffer_); + this->log_callback_.call(message->level, message->tag, this->tx_buffer_); // At this point all the data we need from message has been transferred to the tx_buffer // so we can release the message to allow other tasks to use it as soon as possible. this->log_buffer_->release_message_main_loop(received_token); diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 6030d9e8f2..9f09208b66 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -156,7 +156,6 @@ class Logger : public Component { #endif protected: - void call_log_callbacks_(int level, const char *tag, const char *msg); void write_msg_(const char *msg); // Format a log message with printf-style arguments and write it to a buffer with header, footer, and null terminator @@ -191,7 +190,7 @@ class Logger : public Component { if (this->baud_rate_ > 0) { this->write_msg_(this->tx_buffer_); // If logging is enabled, write to console } - this->call_log_callbacks_(level, tag, this->tx_buffer_); + this->log_callback_.call(level, tag, this->tx_buffer_); } // Write the body of the log message to the buffer From 0454dd4e0720df427aa33c877739e2d4363def60 Mon Sep 17 00:00:00 2001 From: dhewg Date: Sun, 15 Jun 2025 20:16:33 +0200 Subject: [PATCH 17/27] [fan] fix initial FanCall to properly set speed (#8277) --- esphome/components/fan/fan.cpp | 55 ++++--- .../fixtures/host_mode_fan_preset.yaml | 34 ++++ .../integration/test_host_mode_fan_preset.py | 152 ++++++++++++++++++ 3 files changed, 218 insertions(+), 23 deletions(-) create mode 100644 tests/integration/fixtures/host_mode_fan_preset.yaml create mode 100644 tests/integration/test_host_mode_fan_preset.py diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 87bf4939a0..25f710f893 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -41,39 +41,48 @@ void FanCall::perform() { void FanCall::validate_() { auto traits = this->parent_.get_traits(); - if (this->speed_.has_value()) + if (this->speed_.has_value()) { this->speed_ = clamp(*this->speed_, 1, traits.supported_speed_count()); - if (this->binary_state_.has_value() && *this->binary_state_) { - // when turning on, if neither current nor new speed available, set speed to 100% - if (traits.supports_speed() && !this->parent_.state && this->parent_.speed == 0 && !this->speed_.has_value()) { - this->speed_ = traits.supported_speed_count(); - } - } - - if (this->oscillating_.has_value() && !traits.supports_oscillation()) { - ESP_LOGW(TAG, "'%s' - This fan does not support oscillation!", this->parent_.get_name().c_str()); - this->oscillating_.reset(); - } - - if (this->speed_.has_value() && !traits.supports_speed()) { - ESP_LOGW(TAG, "'%s' - This fan does not support speeds!", this->parent_.get_name().c_str()); - this->speed_.reset(); - } - - if (this->direction_.has_value() && !traits.supports_direction()) { - ESP_LOGW(TAG, "'%s' - This fan does not support directions!", this->parent_.get_name().c_str()); - this->direction_.reset(); + // https://developers.home-assistant.io/docs/core/entity/fan/#preset-modes + // "Manually setting a speed must disable any set preset mode" + this->preset_mode_.clear(); } if (!this->preset_mode_.empty()) { const auto &preset_modes = traits.supported_preset_modes(); if (preset_modes.find(this->preset_mode_) == preset_modes.end()) { - ESP_LOGW(TAG, "'%s' - This fan does not support preset mode '%s'!", this->parent_.get_name().c_str(), - this->preset_mode_.c_str()); + ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), this->preset_mode_.c_str()); this->preset_mode_.clear(); } } + + // when turning on... + if (!this->parent_.state && this->binary_state_.has_value() && + *this->binary_state_ + // ..,and no preset mode will be active... + && this->preset_mode_.empty() && + this->parent_.preset_mode.empty() + // ...and neither current nor new speed is available... + && traits.supports_speed() && this->parent_.speed == 0 && !this->speed_.has_value()) { + // ...set speed to 100% + this->speed_ = traits.supported_speed_count(); + } + + if (this->oscillating_.has_value() && !traits.supports_oscillation()) { + ESP_LOGW(TAG, "%s: Oscillation not supported", this->parent_.get_name().c_str()); + this->oscillating_.reset(); + } + + if (this->speed_.has_value() && !traits.supports_speed()) { + ESP_LOGW(TAG, "%s: Speed control not supported", this->parent_.get_name().c_str()); + this->speed_.reset(); + } + + if (this->direction_.has_value() && !traits.supports_direction()) { + ESP_LOGW(TAG, "%s: Direction control not supported", this->parent_.get_name().c_str()); + this->direction_.reset(); + } } FanCall FanRestoreState::to_call(Fan &fan) { diff --git a/tests/integration/fixtures/host_mode_fan_preset.yaml b/tests/integration/fixtures/host_mode_fan_preset.yaml new file mode 100644 index 0000000000..003f4a7760 --- /dev/null +++ b/tests/integration/fixtures/host_mode_fan_preset.yaml @@ -0,0 +1,34 @@ +esphome: + name: host-test + +host: + +api: + +logger: + +# Test fan with preset modes and speed settings +fan: + - platform: template + name: "Test Fan with Presets" + id: test_fan_presets + speed_count: 5 + preset_modes: + - "Eco" + - "Sleep" + - "Turbo" + has_oscillating: true + has_direction: true + + - platform: template + name: "Test Fan Simple" + id: test_fan_simple + speed_count: 3 + has_oscillating: false + has_direction: false + + - platform: template + name: "Test Fan No Speed" + id: test_fan_no_speed + has_oscillating: true + has_direction: false diff --git a/tests/integration/test_host_mode_fan_preset.py b/tests/integration/test_host_mode_fan_preset.py new file mode 100644 index 0000000000..1d956a7290 --- /dev/null +++ b/tests/integration/test_host_mode_fan_preset.py @@ -0,0 +1,152 @@ +"""Integration test for fan preset mode behavior.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import FanInfo, FanState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_mode_fan_preset( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test fan preset mode behavior according to Home Assistant guidelines.""" + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Get all fan entities + entities = await client.list_entities_services() + fans: list[FanInfo] = [] + for entity_list in entities: + for entity in entity_list: + if isinstance(entity, FanInfo): + fans.append(entity) + + # Create a map of fan names to entity info + fan_map = {fan.name: fan for fan in fans} + + # Verify we have our test fans + assert "Test Fan with Presets" in fan_map + assert "Test Fan Simple" in fan_map + assert "Test Fan No Speed" in fan_map + + # Get fan with presets + fan_presets = fan_map["Test Fan with Presets"] + assert fan_presets.supports_speed is True + assert fan_presets.supported_speed_count == 5 + assert fan_presets.supports_oscillation is True + assert fan_presets.supports_direction is True + assert set(fan_presets.supported_preset_modes) == {"Eco", "Sleep", "Turbo"} + + # Subscribe to states + states: dict[int, FanState] = {} + state_event = asyncio.Event() + + def on_state(state: FanState) -> None: + if isinstance(state, FanState): + states[state.key] = state + state_event.set() + + client.subscribe_states(on_state) + + # Test 1: Turn on fan without speed or preset - should set speed to 100% + state_event.clear() + client.fan_command( + key=fan_presets.key, + state=True, + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + fan_state = states[fan_presets.key] + assert fan_state.state is True + assert fan_state.speed_level == 5 # Should be max speed (100%) + assert fan_state.preset_mode == "" + + # Turn off + state_event.clear() + client.fan_command( + key=fan_presets.key, + state=False, + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + # Test 2: Turn on fan with preset mode - should NOT set speed to 100% + state_event.clear() + client.fan_command( + key=fan_presets.key, + state=True, + preset_mode="Eco", + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + fan_state = states[fan_presets.key] + assert fan_state.state is True + assert fan_state.preset_mode == "Eco" + # Speed should be whatever the preset sets, not forced to 100% + + # Test 3: Setting speed should clear preset mode + state_event.clear() + client.fan_command( + key=fan_presets.key, + speed_level=3, + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + fan_state = states[fan_presets.key] + assert fan_state.state is True + assert fan_state.speed_level == 3 + assert fan_state.preset_mode == "" # Preset mode should be cleared + + # Test 4: Setting preset mode should work when fan is already on + state_event.clear() + client.fan_command( + key=fan_presets.key, + preset_mode="Sleep", + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + fan_state = states[fan_presets.key] + assert fan_state.state is True + assert fan_state.preset_mode == "Sleep" + + # Turn off + state_event.clear() + client.fan_command( + key=fan_presets.key, + state=False, + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + # Test 5: Turn on fan with specific speed + state_event.clear() + client.fan_command( + key=fan_presets.key, + state=True, + speed_level=2, + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + fan_state = states[fan_presets.key] + assert fan_state.state is True + assert fan_state.speed_level == 2 + assert fan_state.preset_mode == "" + + # Test 6: Test fan with no speed support + fan_no_speed = fan_map["Test Fan No Speed"] + assert fan_no_speed.supports_speed is False + + state_event.clear() + client.fan_command( + key=fan_no_speed.key, + state=True, + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + fan_state = states[fan_no_speed.key] + assert fan_state.state is True + # No speed should be set for fans that don't support speed From 39f6f9b0dc60a7c434810216dacd5f14ac33fc43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Jun 2025 15:53:45 -0500 Subject: [PATCH 18/27] Implement a lock free ring buffer for BLEScanResult to avoid drops (#9087) --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 88 ++++++++++++------- .../esp32_ble_tracker/esp32_ble_tracker.h | 14 ++- 2 files changed, 69 insertions(+), 33 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index ab3efc3ad3..c5906779f1 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -51,15 +51,14 @@ void ESP32BLETracker::setup() { return; } RAMAllocator allocator; - this->scan_result_buffer_ = allocator.allocate(SCAN_RESULT_BUFFER_SIZE); + this->scan_ring_buffer_ = allocator.allocate(SCAN_RESULT_BUFFER_SIZE); - if (this->scan_result_buffer_ == nullptr) { - ESP_LOGE(TAG, "Could not allocate buffer for BLE Tracker!"); + if (this->scan_ring_buffer_ == nullptr) { + ESP_LOGE(TAG, "Could not allocate ring buffer for BLE Tracker!"); this->mark_failed(); } global_esp32_ble_tracker = this; - this->scan_result_lock_ = xSemaphoreCreateMutex(); #ifdef USE_OTA ota::get_global_ota_callback()->add_on_state_callback( @@ -119,27 +118,31 @@ void ESP32BLETracker::loop() { } bool promote_to_connecting = discovered && !searching && !connecting; - if (this->scanner_state_ == ScannerState::RUNNING && - this->scan_result_index_ && // if it looks like we have a scan result we will take the lock - xSemaphoreTake(this->scan_result_lock_, 0)) { - uint32_t index = this->scan_result_index_; - if (index >= SCAN_RESULT_BUFFER_SIZE) { - ESP_LOGW(TAG, "Too many BLE events to process. Some devices may not show up."); - } + // Process scan results from lock-free SPSC ring buffer + // Consumer side: This runs in the main loop thread + if (this->scanner_state_ == ScannerState::RUNNING) { + // Load our own index with relaxed ordering (we're the only writer) + size_t read_idx = this->ring_read_index_.load(std::memory_order_relaxed); - if (this->raw_advertisements_) { - for (auto *listener : this->listeners_) { - listener->parse_devices(this->scan_result_buffer_, this->scan_result_index_); - } - for (auto *client : this->clients_) { - client->parse_devices(this->scan_result_buffer_, this->scan_result_index_); - } - } + // Load producer's index with acquire to see their latest writes + size_t write_idx = this->ring_write_index_.load(std::memory_order_acquire); - if (this->parse_advertisements_) { - for (size_t i = 0; i < index; i++) { + while (read_idx != write_idx) { + // Process one result at a time directly from ring buffer + BLEScanResult &scan_result = this->scan_ring_buffer_[read_idx]; + + if (this->raw_advertisements_) { + for (auto *listener : this->listeners_) { + listener->parse_devices(&scan_result, 1); + } + for (auto *client : this->clients_) { + client->parse_devices(&scan_result, 1); + } + } + + if (this->parse_advertisements_) { ESPBTDevice device; - device.parse_scan_rst(this->scan_result_buffer_[i]); + device.parse_scan_rst(scan_result); bool found = false; for (auto *listener : this->listeners_) { @@ -160,9 +163,19 @@ void ESP32BLETracker::loop() { this->print_bt_device_info(device); } } + + // Move to next entry in ring buffer + read_idx = (read_idx + 1) % SCAN_RESULT_BUFFER_SIZE; + + // Store with release to ensure reads complete before index update + this->ring_read_index_.store(read_idx, std::memory_order_release); + } + + // Log dropped results periodically + size_t dropped = this->scan_results_dropped_.exchange(0, std::memory_order_relaxed); + if (dropped > 0) { + ESP_LOGW(TAG, "Dropped %zu BLE scan results due to buffer overflow", dropped); } - this->scan_result_index_ = 0; - xSemaphoreGive(this->scan_result_lock_); } if (this->scanner_state_ == ScannerState::STOPPED) { this->end_of_scan_(); // Change state to IDLE @@ -391,12 +404,27 @@ void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { ESP_LOGV(TAG, "gap_scan_result - event %d", scan_result.search_evt); if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { - if (xSemaphoreTake(this->scan_result_lock_, 0)) { - if (this->scan_result_index_ < SCAN_RESULT_BUFFER_SIZE) { - // Store BLEScanResult directly in our buffer - this->scan_result_buffer_[this->scan_result_index_++] = scan_result; - } - xSemaphoreGive(this->scan_result_lock_); + // Lock-free SPSC ring buffer write (Producer side) + // This runs in the ESP-IDF Bluetooth stack callback thread + // IMPORTANT: Only this thread writes to ring_write_index_ + + // Load our own index with relaxed ordering (we're the only writer) + size_t write_idx = this->ring_write_index_.load(std::memory_order_relaxed); + size_t next_write_idx = (write_idx + 1) % SCAN_RESULT_BUFFER_SIZE; + + // Load consumer's index with acquire to see their latest updates + size_t read_idx = this->ring_read_index_.load(std::memory_order_acquire); + + // Check if buffer is full + if (next_write_idx != read_idx) { + // Write to ring buffer + this->scan_ring_buffer_[write_idx] = scan_result; + + // Store with release to ensure the write is visible before index update + this->ring_write_index_.store(next_write_idx, std::memory_order_release); + } else { + // Buffer full, track dropped results + this->scan_results_dropped_.fetch_add(1, std::memory_order_relaxed); } } else if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { // Scan finished on its own diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 33c0caaa87..16a100fb47 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -6,6 +6,7 @@ #include "esphome/core/helpers.h" #include +#include #include #include @@ -282,9 +283,16 @@ class ESP32BLETracker : public Component, bool ble_was_disabled_{true}; bool raw_advertisements_{false}; bool parse_advertisements_{false}; - SemaphoreHandle_t scan_result_lock_; - size_t scan_result_index_{0}; - BLEScanResult *scan_result_buffer_; + + // Lock-free Single-Producer Single-Consumer (SPSC) ring buffer for scan results + // Producer: ESP-IDF Bluetooth stack callback (gap_scan_event_handler) + // Consumer: ESPHome main loop (loop() method) + // This design ensures zero blocking in the BT callback and prevents scan result loss + BLEScanResult *scan_ring_buffer_; + std::atomic ring_write_index_{0}; // Written only by BT callback (producer) + std::atomic ring_read_index_{0}; // Written only by main loop (consumer) + std::atomic scan_results_dropped_{0}; // Tracks buffer overflow events + esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; int connecting_{0}; From 70d66062d6d2e09f80d7f60456e5ad0f5b0c37b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Jun 2025 16:16:46 -0500 Subject: [PATCH 19/27] Make BLE queue lock free (#9088) --- esphome/components/esp32_ble/ble.cpp | 26 +++++++-- esphome/components/esp32_ble/ble.h | 5 +- esphome/components/esp32_ble/queue.h | 83 ++++++++++++++++------------ 3 files changed, 73 insertions(+), 41 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index ed74d59ef2..8adef79d2f 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -23,9 +23,6 @@ namespace esp32_ble { static const char *const TAG = "esp32_ble"; -// Maximum size of the BLE event queue -static constexpr size_t MAX_BLE_QUEUE_SIZE = SCAN_RESULT_BUFFER_SIZE * 2; - static RAMAllocator EVENT_ALLOCATOR( // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) RAMAllocator::ALLOW_FAILURE | RAMAllocator::ALLOC_INTERNAL); @@ -360,21 +357,38 @@ void ESP32BLE::loop() { if (this->advertising_ != nullptr) { this->advertising_->loop(); } + + // Log dropped events periodically + size_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); + } } template void enqueue_ble_event(Args... args) { - if (global_ble->ble_events_.size() >= MAX_BLE_QUEUE_SIZE) { - ESP_LOGD(TAG, "Event queue full (%zu), dropping event", MAX_BLE_QUEUE_SIZE); + // Check if queue is full before allocating + if (global_ble->ble_events_.full()) { + // Queue is full, drop the event + 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...); - global_ble->ble_events_.push(new_event); + + // 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) // Explicit template instantiations for the friend function diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 6508db1a00..58c064a2ef 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -30,6 +30,9 @@ static constexpr uint8_t SCAN_RESULT_BUFFER_SIZE = 32; static constexpr uint8_t SCAN_RESULT_BUFFER_SIZE = 20; #endif +// Maximum size of the BLE event queue - must be power of 2 for lock-free queue +static constexpr size_t MAX_BLE_QUEUE_SIZE = 64; + uint64_t ble_addr_to_uint64(const esp_bd_addr_t address); // NOLINTNEXTLINE(modernize-use-using) @@ -144,7 +147,7 @@ class ESP32BLE : public Component { std::vector ble_status_event_handlers_; BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; - Queue ble_events_; + LockFreeQueue ble_events_; 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/queue.h b/esphome/components/esp32_ble/queue.h index f69878bf6e..56d2efd18b 100644 --- a/esphome/components/esp32_ble/queue.h +++ b/esphome/components/esp32_ble/queue.h @@ -2,63 +2,78 @@ #ifdef USE_ESP32 -#include -#include - -#include -#include +#include +#include /* * BLE events come in from a separate Task (thread) in the ESP32 stack. Rather - * than trying to deal with various locking strategies, all incoming GAP and GATT - * events will simply be placed on a semaphore guarded queue. The next time the - * component runs loop(), these events are popped off the queue and handed at - * this safer time. + * than using mutex-based locking, this lock-free queue allows the BLE + * task to enqueue events without blocking. The main loop() then processes + * these events at a safer time. + * + * This is a Single-Producer Single-Consumer (SPSC) lock-free ring buffer. + * The BLE task is the only producer, and the main loop() is the only consumer. */ namespace esphome { namespace esp32_ble { -template class Queue { +template class LockFreeQueue { public: - Queue() { m_ = xSemaphoreCreateMutex(); } + LockFreeQueue() : head_(0), tail_(0), dropped_count_(0) {} - void push(T *element) { + bool push(T *element) { if (element == nullptr) - return; - // It is not called from main loop. Thus it won't block main thread. - xSemaphoreTake(m_, portMAX_DELAY); - q_.push(element); - xSemaphoreGive(m_); + return false; + + size_t current_tail = tail_.load(std::memory_order_relaxed); + size_t next_tail = (current_tail + 1) % SIZE; + + if (next_tail == head_.load(std::memory_order_acquire)) { + // Buffer full + dropped_count_.fetch_add(1, std::memory_order_relaxed); + return false; + } + + buffer_[current_tail] = element; + tail_.store(next_tail, std::memory_order_release); + return true; } T *pop() { - T *element = nullptr; + size_t current_head = head_.load(std::memory_order_relaxed); - if (xSemaphoreTake(m_, 5L / portTICK_PERIOD_MS)) { - if (!q_.empty()) { - element = q_.front(); - q_.pop(); - } - xSemaphoreGive(m_); + if (current_head == tail_.load(std::memory_order_acquire)) { + return nullptr; // Empty } + + T *element = buffer_[current_head]; + head_.store((current_head + 1) % SIZE, std::memory_order_release); return element; } size_t size() const { - // Lock-free size check. While std::queue::size() is not thread-safe, we intentionally - // avoid locking here to prevent blocking the BLE callback thread. The size is only - // used to decide whether to drop incoming events when the queue is near capacity. - // With a queue limit of 40-64 events and normal processing, dropping events should - // be extremely rare. When it does approach capacity, being off by 1-2 events is - // acceptable to avoid blocking the BLE stack's time-sensitive callbacks. - // Trade-off: We prefer occasional dropped events over potential BLE stack delays. - return q_.size(); + size_t tail = tail_.load(std::memory_order_acquire); + size_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); } + + 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; + return next_tail == head_.load(std::memory_order_acquire); } protected: - std::queue q_; - SemaphoreHandle_t m_; + T *buffer_[SIZE]; + std::atomic head_; + std::atomic tail_; + std::atomic dropped_count_; }; } // namespace esp32_ble From 9644a6bb9c2724f5b26f3e986426c0e1b6268fcf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Jun 2025 16:19:04 -0500 Subject: [PATCH 20/27] Fix protobuf encoding size mismatch by passing force parameter in encode_string (#9074) --- esphome/components/api/proto.h | 2 +- .../host_mode_empty_string_options.yaml | 58 +++++++++ .../test_host_mode_empty_string_options.py | 110 ++++++++++++++++++ 3 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/host_mode_empty_string_options.yaml create mode 100644 tests/integration/test_host_mode_empty_string_options.py diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 5265c4520d..eb0dbc151b 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -216,7 +216,7 @@ class ProtoWriteBuffer { this->buffer_->insert(this->buffer_->end(), data, data + len); } void encode_string(uint32_t field_id, const std::string &value, bool force = false) { - this->encode_string(field_id, value.data(), value.size()); + this->encode_string(field_id, value.data(), value.size(), force); } void encode_bytes(uint32_t field_id, const uint8_t *data, size_t len, bool force = false) { this->encode_string(field_id, reinterpret_cast(data), len, force); diff --git a/tests/integration/fixtures/host_mode_empty_string_options.yaml b/tests/integration/fixtures/host_mode_empty_string_options.yaml new file mode 100644 index 0000000000..ab8e6cd005 --- /dev/null +++ b/tests/integration/fixtures/host_mode_empty_string_options.yaml @@ -0,0 +1,58 @@ +esphome: + name: host-empty-string-test + +host: + +api: + batch_delay: 50ms + +select: + - platform: template + name: "Select Empty First" + id: select_empty_first + optimistic: true + options: + - "" # Empty string at the beginning + - "Option A" + - "Option B" + - "Option C" + initial_option: "Option A" + + - platform: template + name: "Select Empty Middle" + id: select_empty_middle + optimistic: true + options: + - "Option 1" + - "Option 2" + - "" # Empty string in the middle + - "Option 3" + - "Option 4" + initial_option: "Option 1" + + - platform: template + name: "Select Empty Last" + id: select_empty_last + optimistic: true + options: + - "Choice X" + - "Choice Y" + - "Choice Z" + - "" # Empty string at the end + initial_option: "Choice X" + +# Add a sensor to ensure we have other entities in the list +sensor: + - platform: template + name: "Test Sensor" + id: test_sensor + lambda: |- + return 42.0; + update_interval: 60s + +binary_sensor: + - platform: template + name: "Test Binary Sensor" + id: test_binary_sensor + lambda: |- + return true; diff --git a/tests/integration/test_host_mode_empty_string_options.py b/tests/integration/test_host_mode_empty_string_options.py new file mode 100644 index 0000000000..d2df839a75 --- /dev/null +++ b/tests/integration/test_host_mode_empty_string_options.py @@ -0,0 +1,110 @@ +"""Integration test for protobuf encoding of empty string options in select entities.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityState, SelectInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_mode_empty_string_options( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that select entities with empty string options are correctly encoded in protobuf messages. + + This tests the fix for the bug where the force parameter was not passed in encode_string, + causing empty strings in repeated fields to be skipped during encoding but included in + size calculation, leading to protobuf decoding errors. + """ + # Write, compile and run the ESPHome device, then connect to API + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + # Verify we can get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "host-empty-string-test" + + # Get list of entities - this will encode ListEntitiesSelectResponse messages + # with empty string options that would trigger the bug + entity_info, services = await client.list_entities_services() + + # Find our select entities + select_entities = [e for e in entity_info if isinstance(e, SelectInfo)] + assert len(select_entities) == 3, ( + f"Expected 3 select entities, got {len(select_entities)}" + ) + + # Verify each select entity by name and check their options + selects_by_name = {e.name: e for e in select_entities} + + # Check "Select Empty First" - empty string at beginning + assert "Select Empty First" in selects_by_name + empty_first = selects_by_name["Select Empty First"] + assert len(empty_first.options) == 4 + assert empty_first.options[0] == "" # Empty string at beginning + assert empty_first.options[1] == "Option A" + assert empty_first.options[2] == "Option B" + assert empty_first.options[3] == "Option C" + + # Check "Select Empty Middle" - empty string in middle + assert "Select Empty Middle" in selects_by_name + empty_middle = selects_by_name["Select Empty Middle"] + assert len(empty_middle.options) == 5 + assert empty_middle.options[0] == "Option 1" + assert empty_middle.options[1] == "Option 2" + assert empty_middle.options[2] == "" # Empty string in middle + assert empty_middle.options[3] == "Option 3" + assert empty_middle.options[4] == "Option 4" + + # Check "Select Empty Last" - empty string at end + assert "Select Empty Last" in selects_by_name + empty_last = selects_by_name["Select Empty Last"] + assert len(empty_last.options) == 4 + assert empty_last.options[0] == "Choice X" + assert empty_last.options[1] == "Choice Y" + assert empty_last.options[2] == "Choice Z" + assert empty_last.options[3] == "" # Empty string at end + + # If we got here without protobuf decoding errors, the fix is working + # The bug would have caused "Invalid protobuf message" errors with trailing bytes + + # Also verify we can interact with the select entities + # Subscribe to state changes + states: dict[int, EntityState] = {} + state_change_future: asyncio.Future[None] = loop.create_future() + + def on_state(state: EntityState) -> None: + """Track state changes.""" + states[state.key] = state + # When we receive the state change for our select, resolve the future + if state.key == empty_first.key and not state_change_future.done(): + state_change_future.set_result(None) + + client.subscribe_states(on_state) + + # Try setting a select to an empty string option + # This further tests that empty strings are handled correctly + client.select_command(empty_first.key, "") + + # Wait for state update with timeout + try: + await asyncio.wait_for(state_change_future, timeout=5.0) + except asyncio.TimeoutError: + pytest.fail( + "Did not receive state update after setting select to empty string" + ) + + # Verify the state was set to empty string + assert empty_first.key in states + select_state = states[empty_first.key] + assert hasattr(select_state, "state") + assert select_state.state == "" + + # The test passes if no protobuf decoding errors occurred + # With the bug, we would have gotten "Invalid protobuf message" errors From 242b02a416403c48247f30a72ca2b5467ee26053 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 15 Jun 2025 22:19:50 +0100 Subject: [PATCH 21/27] [i2s_audio] Check for a nullptr before disabling and deleting channel (#9062) --- .../microphone/i2s_audio_microphone.cpp | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 1ce98d51d3..52d0ae34fb 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -317,15 +317,18 @@ void I2SAudioMicrophone::stop_driver_() { ESP_LOGW(TAG, "Error uninstalling I2S driver - it may not have started: %s", esp_err_to_name(err)); } #else - /* 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)); - } - /* 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)); + 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)); + } + /* 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)); + } + this->rx_handle_ = nullptr; } #endif this->parent_->unlock(); From 497d66f7ec4bad3b0d5f2736198f313c3b903bcd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Jun 2025 16:22:14 -0500 Subject: [PATCH 22/27] Ensure we can send batches where the first message exceeds MAX_PACKET_SIZE (#9068) --- esphome/components/api/api_connection.cpp | 8 +- tests/integration/conftest.py | 16 +- .../fixtures/api_message_size_batching.yaml | 161 +++++++++++++++ .../fixtures/large_message_batching.yaml | 137 +++++++++++++ .../test_api_message_size_batching.py | 194 ++++++++++++++++++ .../test_large_message_batching.py | 59 ++++++ 6 files changed, 570 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/api_message_size_batching.yaml create mode 100644 tests/integration/fixtures/large_message_batching.yaml create mode 100644 tests/integration/test_api_message_size_batching.py create mode 100644 tests/integration/test_large_message_batching.py diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ca6e2a2d56..8328f5d2cd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1807,7 +1807,7 @@ void APIConnection::process_batch_() { this->batch_first_message_ = true; size_t items_processed = 0; - uint32_t remaining_size = MAX_PACKET_SIZE; + uint16_t remaining_size = std::numeric_limits::max(); // Track where each message's header padding begins in the buffer // For plaintext: this is where the 6-byte header padding starts @@ -1832,11 +1832,15 @@ void APIConnection::process_batch_() { packet_info.emplace_back(item.message_type, current_offset, proto_payload_size); // Update tracking variables + items_processed++; + // After first message, set remaining size to MAX_PACKET_SIZE to avoid fragmentation + if (items_processed == 1) { + remaining_size = MAX_PACKET_SIZE; + } remaining_size -= payload_size; // Calculate where the next message's header padding will start // Current buffer size + footer space (that prepare_message_buffer will add for this message) current_offset = this->parent_->get_shared_buffer_ref().size() + footer_size; - items_processed++; } if (items_processed == 0) { diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 4eb1584c27..90377300a6 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -15,7 +15,7 @@ import sys import tempfile from typing import TextIO -from aioesphomeapi import APIClient, APIConnectionError, ReconnectLogic +from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic import pytest import pytest_asyncio @@ -365,11 +365,21 @@ async def _read_stream_lines( stream: asyncio.StreamReader, lines: list[str], output_stream: TextIO ) -> None: """Read lines from a stream, append to list, and echo to output stream.""" + log_parser = LogParser() while line := await stream.readline(): - decoded_line = line.decode("utf-8", errors="replace") + decoded_line = ( + line.replace(b"\r", b"") + .replace(b"\n", b"") + .decode("utf8", "backslashreplace") + ) lines.append(decoded_line.rstrip()) # Echo to stdout/stderr in real-time - print(decoded_line.rstrip(), file=output_stream, flush=True) + # Print without newline to avoid double newlines + print( + log_parser.parse_line(decoded_line, timestamp=""), + file=output_stream, + flush=True, + ) @asynccontextmanager diff --git a/tests/integration/fixtures/api_message_size_batching.yaml b/tests/integration/fixtures/api_message_size_batching.yaml new file mode 100644 index 0000000000..c730dc1aa3 --- /dev/null +++ b/tests/integration/fixtures/api_message_size_batching.yaml @@ -0,0 +1,161 @@ +esphome: + name: message-size-batching-test +host: +api: +# Default batch_delay to test batching +logger: + +# Create entities that will produce different protobuf header sizes +# Header size depends on: 1 byte indicator + varint(payload_size) + varint(message_type) +# 4-byte header: type < 128, payload < 128 +# 5-byte header: type < 128, payload 128-16383 OR type 128+, payload < 128 +# 6-byte header: type 128+, payload 128-16383 + +# Small select with few options - produces small message +select: + - platform: template + name: "Small Select" + id: small_select + optimistic: true + options: + - "Option A" + - "Option B" + initial_option: "Option A" + update_interval: 5.0s + + # Medium select with more options - produces medium message + - platform: template + name: "Medium Select" + id: medium_select + optimistic: true + options: + - "Option 001" + - "Option 002" + - "Option 003" + - "Option 004" + - "Option 005" + - "Option 006" + - "Option 007" + - "Option 008" + - "Option 009" + - "Option 010" + - "Option 011" + - "Option 012" + - "Option 013" + - "Option 014" + - "Option 015" + - "Option 016" + - "Option 017" + - "Option 018" + - "Option 019" + - "Option 020" + initial_option: "Option 001" + update_interval: 5.0s + + # Large select with many options - produces larger message + - platform: template + name: "Large Select with Many Options to Create Larger Payload" + id: large_select + optimistic: true + options: + - "Long Option Name 001 - This is a longer option name to increase message size" + - "Long Option Name 002 - This is a longer option name to increase message size" + - "Long Option Name 003 - This is a longer option name to increase message size" + - "Long Option Name 004 - This is a longer option name to increase message size" + - "Long Option Name 005 - This is a longer option name to increase message size" + - "Long Option Name 006 - This is a longer option name to increase message size" + - "Long Option Name 007 - This is a longer option name to increase message size" + - "Long Option Name 008 - This is a longer option name to increase message size" + - "Long Option Name 009 - This is a longer option name to increase message size" + - "Long Option Name 010 - This is a longer option name to increase message size" + - "Long Option Name 011 - This is a longer option name to increase message size" + - "Long Option Name 012 - This is a longer option name to increase message size" + - "Long Option Name 013 - This is a longer option name to increase message size" + - "Long Option Name 014 - This is a longer option name to increase message size" + - "Long Option Name 015 - This is a longer option name to increase message size" + - "Long Option Name 016 - This is a longer option name to increase message size" + - "Long Option Name 017 - This is a longer option name to increase message size" + - "Long Option Name 018 - This is a longer option name to increase message size" + - "Long Option Name 019 - This is a longer option name to increase message size" + - "Long Option Name 020 - This is a longer option name to increase message size" + - "Long Option Name 021 - This is a longer option name to increase message size" + - "Long Option Name 022 - This is a longer option name to increase message size" + - "Long Option Name 023 - This is a longer option name to increase message size" + - "Long Option Name 024 - This is a longer option name to increase message size" + - "Long Option Name 025 - This is a longer option name to increase message size" + - "Long Option Name 026 - This is a longer option name to increase message size" + - "Long Option Name 027 - This is a longer option name to increase message size" + - "Long Option Name 028 - This is a longer option name to increase message size" + - "Long Option Name 029 - This is a longer option name to increase message size" + - "Long Option Name 030 - This is a longer option name to increase message size" + - "Long Option Name 031 - This is a longer option name to increase message size" + - "Long Option Name 032 - This is a longer option name to increase message size" + - "Long Option Name 033 - This is a longer option name to increase message size" + - "Long Option Name 034 - This is a longer option name to increase message size" + - "Long Option Name 035 - This is a longer option name to increase message size" + - "Long Option Name 036 - This is a longer option name to increase message size" + - "Long Option Name 037 - This is a longer option name to increase message size" + - "Long Option Name 038 - This is a longer option name to increase message size" + - "Long Option Name 039 - This is a longer option name to increase message size" + - "Long Option Name 040 - This is a longer option name to increase message size" + - "Long Option Name 041 - This is a longer option name to increase message size" + - "Long Option Name 042 - This is a longer option name to increase message size" + - "Long Option Name 043 - This is a longer option name to increase message size" + - "Long Option Name 044 - This is a longer option name to increase message size" + - "Long Option Name 045 - This is a longer option name to increase message size" + - "Long Option Name 046 - This is a longer option name to increase message size" + - "Long Option Name 047 - This is a longer option name to increase message size" + - "Long Option Name 048 - This is a longer option name to increase message size" + - "Long Option Name 049 - This is a longer option name to increase message size" + - "Long Option Name 050 - This is a longer option name to increase message size" + initial_option: "Long Option Name 001 - This is a longer option name to increase message size" + update_interval: 5.0s + +# Text sensors with different value lengths +text_sensor: + - platform: template + name: "Short Text Sensor" + id: short_text_sensor + lambda: |- + return {"OK"}; + update_interval: 5.0s + + - platform: template + name: "Medium Text Sensor" + id: medium_text_sensor + lambda: |- + return {"This is a medium length text sensor value that should produce a medium sized message"}; + update_interval: 5.0s + + - platform: template + name: "Long Text Sensor with Very Long Value" + id: long_text_sensor + lambda: |- + return {"This is a very long text sensor value that contains a lot of text to ensure we get a larger protobuf message. The message should be long enough to require a 2-byte varint for the payload size, which happens when the payload exceeds 127 bytes. Let's add even more text here to make sure we exceed that threshold and test the batching of messages with different header sizes properly."}; + update_interval: 5.0s + +# Text input which can have various lengths +text: + - platform: template + name: "Test Text Input" + id: test_text_input + optimistic: true + mode: text + min_length: 0 + max_length: 255 + initial_value: "Initial value" + update_interval: 5.0s + +# Number entity to add variety (different message type number) +# The ListEntitiesNumberResponse has message type 49 +# The NumberStateResponse has message type 50 +number: + - platform: template + name: "Test Number with Long Name to Increase Message Size" + id: test_number + optimistic: true + min_value: 0 + max_value: 1000 + step: 0.1 + initial_value: 42.0 + update_interval: 5.0s diff --git a/tests/integration/fixtures/large_message_batching.yaml b/tests/integration/fixtures/large_message_batching.yaml new file mode 100644 index 0000000000..1b2d817cd4 --- /dev/null +++ b/tests/integration/fixtures/large_message_batching.yaml @@ -0,0 +1,137 @@ +esphome: + name: large-message-test +host: +api: +logger: + +# Create a select entity with many options to exceed 1390 bytes +select: + - platform: template + name: "Large Select" + id: large_select + optimistic: true + options: + - "Option 000 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 001 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 002 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 003 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 004 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 005 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 006 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 007 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 008 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 009 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 010 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 011 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 012 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 013 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 014 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 015 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 016 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 017 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 018 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 019 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 020 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 021 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 022 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 023 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 024 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 025 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 026 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 027 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 028 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 029 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 030 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 031 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 032 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 033 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 034 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 035 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 036 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 037 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 038 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 039 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 040 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 041 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 042 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 043 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 044 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 045 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 046 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 047 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 048 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 049 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 050 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 051 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 052 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 053 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 054 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 055 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 056 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 057 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 058 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 059 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 060 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 061 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 062 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 063 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 064 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 065 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 066 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 067 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 068 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 069 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 070 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 071 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 072 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 073 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 074 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 075 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 076 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 077 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 078 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 079 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 080 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 081 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 082 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 083 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 084 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 085 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 086 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 087 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 088 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 089 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 090 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 091 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 092 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 093 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 094 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 095 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 096 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 097 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 098 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + - "Option 099 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + initial_option: "Option 000 - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + +# Add some other entities to test batching with the large select +sensor: + - platform: template + name: "Test Sensor" + id: test_sensor + lambda: |- + return 42.0; + update_interval: 1s + +binary_sensor: + - platform: template + name: "Test Binary Sensor" + id: test_binary_sensor + lambda: |- + return true; + +switch: + - platform: template + name: "Test Switch" + id: test_switch + optimistic: true + diff --git a/tests/integration/test_api_message_size_batching.py b/tests/integration/test_api_message_size_batching.py new file mode 100644 index 0000000000..631e64825e --- /dev/null +++ b/tests/integration/test_api_message_size_batching.py @@ -0,0 +1,194 @@ +"""Integration test for API batching with various message sizes.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityState, NumberInfo, SelectInfo, TextInfo, TextSensorInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_message_size_batching( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test API can batch messages of various sizes correctly.""" + # Write, compile and run the ESPHome device, then connect to API + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + # Verify we can get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "message-size-batching-test" + + # List entities - this will batch various sized messages together + entity_info, services = await asyncio.wait_for( + client.list_entities_services(), timeout=5.0 + ) + + # Count different entity types + selects = [] + text_sensors = [] + text_inputs = [] + numbers = [] + other_entities = [] + + for entity in entity_info: + if isinstance(entity, SelectInfo): + selects.append(entity) + elif isinstance(entity, TextSensorInfo): + text_sensors.append(entity) + elif isinstance(entity, TextInfo): + text_inputs.append(entity) + elif isinstance(entity, NumberInfo): + numbers.append(entity) + else: + other_entities.append(entity) + + # Verify we have our test entities - exact counts + assert len(selects) == 3, ( + f"Expected exactly 3 select entities, got {len(selects)}" + ) + assert len(text_sensors) == 3, ( + f"Expected exactly 3 text sensor entities, got {len(text_sensors)}" + ) + assert len(text_inputs) == 1, ( + f"Expected exactly 1 text input entity, got {len(text_inputs)}" + ) + + # Collect all select entity object_ids for error messages + select_ids = [s.object_id for s in selects] + + # Find our specific test entities + small_select = None + medium_select = None + large_select = None + + for select in selects: + if select.object_id == "small_select": + small_select = select + elif select.object_id == "medium_select": + medium_select = select + elif ( + select.object_id + == "large_select_with_many_options_to_create_larger_payload" + ): + large_select = select + + assert small_select is not None, ( + f"Could not find small_select entity. Found: {select_ids}" + ) + assert medium_select is not None, ( + f"Could not find medium_select entity. Found: {select_ids}" + ) + assert large_select is not None, ( + f"Could not find large_select entity. Found: {select_ids}" + ) + + # Verify the selects have the expected number of options + assert len(small_select.options) == 2, ( + f"Expected 2 options for small_select, got {len(small_select.options)}" + ) + assert len(medium_select.options) == 20, ( + f"Expected 20 options for medium_select, got {len(medium_select.options)}" + ) + assert len(large_select.options) == 50, ( + f"Expected 50 options for large_select, got {len(large_select.options)}" + ) + + # Collect all text sensor object_ids for error messages + text_sensor_ids = [t.object_id for t in text_sensors] + + # Verify text sensors with different value lengths + short_text_sensor = None + medium_text_sensor = None + long_text_sensor = None + + for text_sensor in text_sensors: + if text_sensor.object_id == "short_text_sensor": + short_text_sensor = text_sensor + elif text_sensor.object_id == "medium_text_sensor": + medium_text_sensor = text_sensor + elif text_sensor.object_id == "long_text_sensor_with_very_long_value": + long_text_sensor = text_sensor + + assert short_text_sensor is not None, ( + f"Could not find short_text_sensor. Found: {text_sensor_ids}" + ) + assert medium_text_sensor is not None, ( + f"Could not find medium_text_sensor. Found: {text_sensor_ids}" + ) + assert long_text_sensor is not None, ( + f"Could not find long_text_sensor. Found: {text_sensor_ids}" + ) + + # Check text input which can have a long max_length + text_input = None + text_input_ids = [t.object_id for t in text_inputs] + + for ti in text_inputs: + if ti.object_id == "test_text_input": + text_input = ti + break + + assert text_input is not None, ( + f"Could not find test_text_input. Found: {text_input_ids}" + ) + assert text_input.max_length == 255, ( + f"Expected max_length 255, got {text_input.max_length}" + ) + + # Verify total entity count - messages of various sizes were batched successfully + # We have: 3 selects + 3 text sensors + 1 text input + 1 number = 8 total + total_entities = len(entity_info) + assert total_entities == 8, f"Expected exactly 8 entities, got {total_entities}" + + # Check we have the expected entity types + assert len(numbers) == 1, ( + f"Expected exactly 1 number entity, got {len(numbers)}" + ) + assert len(other_entities) == 0, ( + f"Unexpected entity types found: {[type(e).__name__ for e in other_entities]}" + ) + + # Subscribe to state changes to verify batching works + # Collect keys from entity info to know what states to expect + expected_keys = {entity.key for entity in entity_info} + assert len(expected_keys) == 8, ( + f"Expected 8 unique entity keys, got {len(expected_keys)}" + ) + + received_keys: set[int] = set() + states_future: asyncio.Future[None] = loop.create_future() + + def on_state(state: EntityState) -> None: + """Track when states are received.""" + received_keys.add(state.key) + # Check if we've received states from all expected entities + if expected_keys.issubset(received_keys) and not states_future.done(): + states_future.set_result(None) + + client.subscribe_states(on_state) + + # Wait for states with timeout + try: + await asyncio.wait_for(states_future, timeout=5.0) + except asyncio.TimeoutError: + missing_keys = expected_keys - received_keys + pytest.fail( + f"Did not receive states from all entities within 5 seconds. " + f"Missing keys: {missing_keys}, " + f"Received {len(received_keys)} of {len(expected_keys)} expected states" + ) + + # Verify we received states from all entities + assert expected_keys.issubset(received_keys) + + # Check that various message sizes were handled correctly + # Small messages (4-byte header): type < 128, payload < 128 + # Medium messages (5-byte header): type < 128, payload 128-16383 OR type 128+, payload < 128 + # Large messages (6-byte header): type 128+, payload 128-16383 diff --git a/tests/integration/test_large_message_batching.py b/tests/integration/test_large_message_batching.py new file mode 100644 index 0000000000..399fd39dd3 --- /dev/null +++ b/tests/integration/test_large_message_batching.py @@ -0,0 +1,59 @@ +"""Integration test for API handling of large messages exceeding batch size.""" + +from __future__ import annotations + +from aioesphomeapi import SelectInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_large_message_batching( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test API can handle large messages (>1390 bytes) in batches.""" + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Verify we can get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "large-message-test" + + # List entities - this will include our select with many options + entity_info, services = await client.list_entities_services() + + # Find our large select entity + large_select = None + for entity in entity_info: + if isinstance(entity, SelectInfo) and entity.object_id == "large_select": + large_select = entity + break + + assert large_select is not None, "Could not find large_select entity" + + # Verify the select has all its options + # We created 100 options with long names + assert len(large_select.options) == 100, ( + f"Expected 100 options, got {len(large_select.options)}" + ) + + # Verify all options are present and correct + for i in range(100): + expected_option = f"Option {i:03d} - This is a very long option name to make the message larger than the typical batch size of 1390 bytes" + assert expected_option in large_select.options, ( + f"Missing option: {expected_option}" + ) + + # Also verify we can still receive other entities in the same batch + # Count total entities - should have at least our select plus some sensors + entity_count = len(entity_info) + assert entity_count >= 4, f"Expected at least 4 entities, got {entity_count}" + + # Verify we have different entity types (not just selects) + entity_types = {type(entity).__name__ for entity in entity_info} + assert len(entity_types) >= 2, ( + f"Expected multiple entity types, got {entity_types}" + ) From e435e726540f2a91c00810d22bb7b3d8764eab0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Jun 2025 16:29:25 -0500 Subject: [PATCH 23/27] Add common base classes for entity protobuf messages to reduce duplicate code (#9090) --- esphome/components/api/api.proto | 44 ++++ esphome/components/api/api_connection.cpp | 44 ++-- esphome/components/api/api_connection.h | 9 +- esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 1 + esphome/components/api/api_pb2.h | 291 +++++----------------- script/api_protobuf/api_protobuf.py | 171 ++++++++++++- 7 files changed, 306 insertions(+), 255 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c5c63b8dfc..843b72795a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -266,6 +266,7 @@ enum EntityCategory { // ==================== BINARY SENSOR ==================== message ListEntitiesBinarySensorResponse { option (id) = 12; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_BINARY_SENSOR"; @@ -282,6 +283,7 @@ message ListEntitiesBinarySensorResponse { } message BinarySensorStateResponse { option (id) = 21; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_BINARY_SENSOR"; option (no_delay) = true; @@ -296,6 +298,7 @@ message BinarySensorStateResponse { // ==================== COVER ==================== message ListEntitiesCoverResponse { option (id) = 13; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_COVER"; @@ -325,6 +328,7 @@ enum CoverOperation { } message CoverStateResponse { option (id) = 22; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_COVER"; option (no_delay) = true; @@ -367,6 +371,7 @@ message CoverCommandRequest { // ==================== FAN ==================== message ListEntitiesFanResponse { option (id) = 14; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_FAN"; @@ -395,6 +400,7 @@ enum FanDirection { } message FanStateResponse { option (id) = 23; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_FAN"; option (no_delay) = true; @@ -444,6 +450,7 @@ enum ColorMode { } message ListEntitiesLightResponse { option (id) = 15; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_LIGHT"; @@ -467,6 +474,7 @@ message ListEntitiesLightResponse { } message LightStateResponse { option (id) = 24; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_LIGHT"; option (no_delay) = true; @@ -536,6 +544,7 @@ enum SensorLastResetType { message ListEntitiesSensorResponse { option (id) = 16; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_SENSOR"; @@ -557,6 +566,7 @@ message ListEntitiesSensorResponse { } message SensorStateResponse { option (id) = 25; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_SENSOR"; option (no_delay) = true; @@ -571,6 +581,7 @@ message SensorStateResponse { // ==================== SWITCH ==================== message ListEntitiesSwitchResponse { option (id) = 17; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_SWITCH"; @@ -587,6 +598,7 @@ message ListEntitiesSwitchResponse { } message SwitchStateResponse { option (id) = 26; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_SWITCH"; option (no_delay) = true; @@ -607,6 +619,7 @@ message SwitchCommandRequest { // ==================== TEXT SENSOR ==================== message ListEntitiesTextSensorResponse { option (id) = 18; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_TEXT_SENSOR"; @@ -622,6 +635,7 @@ message ListEntitiesTextSensorResponse { } message TextSensorStateResponse { option (id) = 27; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_TEXT_SENSOR"; option (no_delay) = true; @@ -789,6 +803,7 @@ message ExecuteServiceRequest { // ==================== CAMERA ==================== message ListEntitiesCameraResponse { option (id) = 43; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_ESP32_CAMERA"; @@ -869,6 +884,7 @@ enum ClimatePreset { } message ListEntitiesClimateResponse { option (id) = 46; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_CLIMATE"; @@ -903,6 +919,7 @@ message ListEntitiesClimateResponse { } message ClimateStateResponse { option (id) = 47; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_CLIMATE"; option (no_delay) = true; @@ -964,6 +981,7 @@ enum NumberMode { } message ListEntitiesNumberResponse { option (id) = 49; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_NUMBER"; @@ -984,6 +1002,7 @@ message ListEntitiesNumberResponse { } message NumberStateResponse { option (id) = 50; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_NUMBER"; option (no_delay) = true; @@ -1007,6 +1026,7 @@ message NumberCommandRequest { // ==================== SELECT ==================== message ListEntitiesSelectResponse { option (id) = 52; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_SELECT"; @@ -1022,6 +1042,7 @@ message ListEntitiesSelectResponse { } message SelectStateResponse { option (id) = 53; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_SELECT"; option (no_delay) = true; @@ -1045,6 +1066,7 @@ message SelectCommandRequest { // ==================== SIREN ==================== message ListEntitiesSirenResponse { option (id) = 55; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_SIREN"; @@ -1062,6 +1084,7 @@ message ListEntitiesSirenResponse { } message SirenStateResponse { option (id) = 56; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_SIREN"; option (no_delay) = true; @@ -1102,6 +1125,7 @@ enum LockCommand { } message ListEntitiesLockResponse { option (id) = 58; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_LOCK"; @@ -1123,6 +1147,7 @@ message ListEntitiesLockResponse { } message LockStateResponse { option (id) = 59; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_LOCK"; option (no_delay) = true; @@ -1145,6 +1170,7 @@ message LockCommandRequest { // ==================== BUTTON ==================== message ListEntitiesButtonResponse { option (id) = 61; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_BUTTON"; @@ -1196,6 +1222,7 @@ message MediaPlayerSupportedFormat { } message ListEntitiesMediaPlayerResponse { option (id) = 63; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_MEDIA_PLAYER"; @@ -1214,6 +1241,7 @@ message ListEntitiesMediaPlayerResponse { } message MediaPlayerStateResponse { option (id) = 64; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_MEDIA_PLAYER"; option (no_delay) = true; @@ -1735,6 +1763,7 @@ enum AlarmControlPanelStateCommand { message ListEntitiesAlarmControlPanelResponse { option (id) = 94; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_ALARM_CONTROL_PANEL"; @@ -1752,6 +1781,7 @@ message ListEntitiesAlarmControlPanelResponse { message AlarmControlPanelStateResponse { option (id) = 95; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_ALARM_CONTROL_PANEL"; option (no_delay) = true; @@ -1776,6 +1806,7 @@ enum TextMode { } message ListEntitiesTextResponse { option (id) = 97; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_TEXT"; @@ -1794,6 +1825,7 @@ message ListEntitiesTextResponse { } message TextStateResponse { option (id) = 98; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_TEXT"; option (no_delay) = true; @@ -1818,6 +1850,7 @@ message TextCommandRequest { // ==================== DATETIME DATE ==================== message ListEntitiesDateResponse { option (id) = 100; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_DATETIME_DATE"; @@ -1832,6 +1865,7 @@ message ListEntitiesDateResponse { } message DateStateResponse { option (id) = 101; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_DATETIME_DATE"; option (no_delay) = true; @@ -1859,6 +1893,7 @@ message DateCommandRequest { // ==================== DATETIME TIME ==================== message ListEntitiesTimeResponse { option (id) = 103; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_DATETIME_TIME"; @@ -1873,6 +1908,7 @@ message ListEntitiesTimeResponse { } message TimeStateResponse { option (id) = 104; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_DATETIME_TIME"; option (no_delay) = true; @@ -1900,6 +1936,7 @@ message TimeCommandRequest { // ==================== EVENT ==================== message ListEntitiesEventResponse { option (id) = 107; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_EVENT"; @@ -1917,6 +1954,7 @@ message ListEntitiesEventResponse { } message EventResponse { option (id) = 108; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_EVENT"; @@ -1927,6 +1965,7 @@ message EventResponse { // ==================== VALVE ==================== message ListEntitiesValveResponse { option (id) = 109; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_VALVE"; @@ -1952,6 +1991,7 @@ enum ValveOperation { } message ValveStateResponse { option (id) = 110; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_VALVE"; option (no_delay) = true; @@ -1976,6 +2016,7 @@ message ValveCommandRequest { // ==================== DATETIME DATETIME ==================== message ListEntitiesDateTimeResponse { option (id) = 112; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_DATETIME_DATETIME"; @@ -1990,6 +2031,7 @@ message ListEntitiesDateTimeResponse { } message DateTimeStateResponse { option (id) = 113; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_DATETIME_DATETIME"; option (no_delay) = true; @@ -2013,6 +2055,7 @@ message DateTimeCommandRequest { // ==================== UPDATE ==================== message ListEntitiesUpdateResponse { option (id) = 116; + option (base_class) = "InfoResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_UPDATE"; @@ -2028,6 +2071,7 @@ message ListEntitiesUpdateResponse { } message UpdateStateResponse { option (id) = 117; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_UPDATE"; option (no_delay) = true; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 8328f5d2cd..3e2b7c0154 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -301,7 +301,7 @@ uint16_t APIConnection::try_send_binary_sensor_state(EntityBase *entity, APIConn BinarySensorStateResponse resp; resp.state = binary_sensor->state; resp.missing_state = !binary_sensor->has_state(); - resp.key = binary_sensor->get_object_id_hash(); + fill_entity_state_base(binary_sensor, resp); return encode_message_to_buffer(resp, BinarySensorStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -335,7 +335,7 @@ uint16_t APIConnection::try_send_cover_state(EntityBase *entity, APIConnection * if (traits.get_supports_tilt()) msg.tilt = cover->tilt; msg.current_operation = static_cast(cover->current_operation); - msg.key = cover->get_object_id_hash(); + fill_entity_state_base(cover, msg); return encode_message_to_buffer(msg, CoverStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } uint16_t APIConnection::try_send_cover_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -403,7 +403,7 @@ uint16_t APIConnection::try_send_fan_state(EntityBase *entity, APIConnection *co msg.direction = static_cast(fan->direction); if (traits.supports_preset_modes()) msg.preset_mode = fan->preset_mode; - msg.key = fan->get_object_id_hash(); + fill_entity_state_base(fan, msg); return encode_message_to_buffer(msg, FanStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } uint16_t APIConnection::try_send_fan_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -470,7 +470,7 @@ uint16_t APIConnection::try_send_light_state(EntityBase *entity, APIConnection * resp.warm_white = values.get_warm_white(); if (light->supports_effects()) resp.effect = light->get_effect_name(); - resp.key = light->get_object_id_hash(); + fill_entity_state_base(light, resp); return encode_message_to_buffer(resp, LightStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -552,7 +552,7 @@ uint16_t APIConnection::try_send_sensor_state(EntityBase *entity, APIConnection SensorStateResponse resp; resp.state = sensor->state; resp.missing_state = !sensor->has_state(); - resp.key = sensor->get_object_id_hash(); + fill_entity_state_base(sensor, resp); return encode_message_to_buffer(resp, SensorStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -586,7 +586,7 @@ uint16_t APIConnection::try_send_switch_state(EntityBase *entity, APIConnection auto *a_switch = static_cast(entity); SwitchStateResponse resp; resp.state = a_switch->state; - resp.key = a_switch->get_object_id_hash(); + fill_entity_state_base(a_switch, resp); return encode_message_to_buffer(resp, SwitchStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -629,7 +629,7 @@ uint16_t APIConnection::try_send_text_sensor_state(EntityBase *entity, APIConnec TextSensorStateResponse resp; resp.state = text_sensor->state; resp.missing_state = !text_sensor->has_state(); - resp.key = text_sensor->get_object_id_hash(); + fill_entity_state_base(text_sensor, resp); return encode_message_to_buffer(resp, TextSensorStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } uint16_t APIConnection::try_send_text_sensor_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -653,7 +653,7 @@ uint16_t APIConnection::try_send_climate_state(EntityBase *entity, APIConnection bool is_single) { auto *climate = static_cast(entity); ClimateStateResponse resp; - resp.key = climate->get_object_id_hash(); + fill_entity_state_base(climate, resp); auto traits = climate->get_traits(); resp.mode = static_cast(climate->mode); resp.action = static_cast(climate->action); @@ -762,7 +762,7 @@ uint16_t APIConnection::try_send_number_state(EntityBase *entity, APIConnection NumberStateResponse resp; resp.state = number->state; resp.missing_state = !number->has_state(); - resp.key = number->get_object_id_hash(); + fill_entity_state_base(number, resp); return encode_message_to_buffer(resp, NumberStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -803,7 +803,7 @@ uint16_t APIConnection::try_send_date_state(EntityBase *entity, APIConnection *c resp.year = date->year; resp.month = date->month; resp.day = date->day; - resp.key = date->get_object_id_hash(); + fill_entity_state_base(date, resp); return encode_message_to_buffer(resp, DateStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } void APIConnection::send_date_info(datetime::DateEntity *date) { @@ -840,7 +840,7 @@ uint16_t APIConnection::try_send_time_state(EntityBase *entity, APIConnection *c resp.hour = time->hour; resp.minute = time->minute; resp.second = time->second; - resp.key = time->get_object_id_hash(); + fill_entity_state_base(time, resp); return encode_message_to_buffer(resp, TimeStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } void APIConnection::send_time_info(datetime::TimeEntity *time) { @@ -879,7 +879,7 @@ uint16_t APIConnection::try_send_datetime_state(EntityBase *entity, APIConnectio ESPTime state = datetime->state_as_esptime(); resp.epoch_seconds = state.timestamp; } - resp.key = datetime->get_object_id_hash(); + fill_entity_state_base(datetime, resp); return encode_message_to_buffer(resp, DateTimeStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } void APIConnection::send_datetime_info(datetime::DateTimeEntity *datetime) { @@ -918,7 +918,7 @@ uint16_t APIConnection::try_send_text_state(EntityBase *entity, APIConnection *c TextStateResponse resp; resp.state = text->state; resp.missing_state = !text->has_state(); - resp.key = text->get_object_id_hash(); + fill_entity_state_base(text, resp); return encode_message_to_buffer(resp, TextStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -959,7 +959,7 @@ uint16_t APIConnection::try_send_select_state(EntityBase *entity, APIConnection SelectStateResponse resp; resp.state = select->state; resp.missing_state = !select->has_state(); - resp.key = select->get_object_id_hash(); + fill_entity_state_base(select, resp); return encode_message_to_buffer(resp, SelectStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -1019,7 +1019,7 @@ uint16_t APIConnection::try_send_lock_state(EntityBase *entity, APIConnection *c auto *a_lock = static_cast(entity); LockStateResponse resp; resp.state = static_cast(a_lock->state); - resp.key = a_lock->get_object_id_hash(); + fill_entity_state_base(a_lock, resp); return encode_message_to_buffer(resp, LockStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -1063,7 +1063,7 @@ uint16_t APIConnection::try_send_valve_state(EntityBase *entity, APIConnection * ValveStateResponse resp; resp.position = valve->position; resp.current_operation = static_cast(valve->current_operation); - resp.key = valve->get_object_id_hash(); + fill_entity_state_base(valve, resp); return encode_message_to_buffer(resp, ValveStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } void APIConnection::send_valve_info(valve::Valve *valve) { @@ -1111,7 +1111,7 @@ uint16_t APIConnection::try_send_media_player_state(EntityBase *entity, APIConne resp.state = static_cast(report_state); resp.volume = media_player->volume; resp.muted = media_player->is_muted(); - resp.key = media_player->get_object_id_hash(); + fill_entity_state_base(media_player, resp); return encode_message_to_buffer(resp, MediaPlayerStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } void APIConnection::send_media_player_info(media_player::MediaPlayer *media_player) { @@ -1375,7 +1375,7 @@ uint16_t APIConnection::try_send_alarm_control_panel_state(EntityBase *entity, A auto *a_alarm_control_panel = static_cast(entity); AlarmControlPanelStateResponse resp; resp.state = static_cast(a_alarm_control_panel->get_state()); - resp.key = a_alarm_control_panel->get_object_id_hash(); + fill_entity_state_base(a_alarm_control_panel, resp); return encode_message_to_buffer(resp, AlarmControlPanelStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } void APIConnection::send_alarm_control_panel_info(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) { @@ -1439,7 +1439,7 @@ uint16_t APIConnection::try_send_event_response(event::Event *event, const std:: uint32_t remaining_size, bool is_single) { EventResponse resp; resp.event_type = event_type; - resp.key = event->get_object_id_hash(); + fill_entity_state_base(event, resp); return encode_message_to_buffer(resp, EventResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -1477,7 +1477,7 @@ uint16_t APIConnection::try_send_update_state(EntityBase *entity, APIConnection resp.release_summary = update->update_info.summary; resp.release_url = update->update_info.release_url; } - resp.key = update->get_object_id_hash(); + fill_entity_state_base(update, resp); return encode_message_to_buffer(resp, UpdateStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } void APIConnection::send_update_info(update::UpdateEntity *update) { @@ -1538,7 +1538,7 @@ bool APIConnection::try_send_log_message(int level, const char *tag, const char buffer.encode_string(3, line, line_length); // string message = 3 // SubscribeLogsResponse - 29 - return this->send_buffer(buffer, 29); + return this->send_buffer(buffer, SubscribeLogsResponse::MESSAGE_TYPE); } HelloResponse APIConnection::hello(const HelloRequest &msg) { @@ -1685,7 +1685,7 @@ bool APIConnection::try_to_clear_buffer(bool log_out_of_space) { return false; } bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) { - if (!this->try_to_clear_buffer(message_type != 29)) { // SubscribeLogsResponse + if (!this->try_to_clear_buffer(message_type != SubscribeLogsResponse::MESSAGE_TYPE)) { // SubscribeLogsResponse return false; } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 13e6066788..7cd41561d4 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -282,8 +282,8 @@ class APIConnection : public APIServerConnection { ProtoWriteBuffer allocate_batch_message_buffer(uint16_t size); protected: - // Helper function to fill common entity fields - template static void fill_entity_info_base(esphome::EntityBase *entity, ResponseT &response) { + // Helper function to fill common entity info fields + static void fill_entity_info_base(esphome::EntityBase *entity, InfoResponseProtoMessage &response) { // Set common fields that are shared by all entity types response.key = entity->get_object_id_hash(); response.object_id = entity->get_object_id(); @@ -297,6 +297,11 @@ class APIConnection : public APIServerConnection { response.entity_category = static_cast(entity->get_entity_category()); } + // Helper function to fill common entity state fields + static void fill_entity_state_base(esphome::EntityBase *entity, StateResponseProtoMessage &response) { + response.key = entity->get_object_id_hash(); + } + // Non-template helper to encode any ProtoMessage static uint16_t encode_message_to_buffer(ProtoMessage &msg, uint16_t message_type, APIConnection *conn, uint32_t remaining_size, bool is_single); diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index feaf39ba15..3a547b8688 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -21,4 +21,5 @@ extend google.protobuf.MessageOptions { optional string ifdef = 1038; optional bool log = 1039 [default=true]; optional bool no_delay = 1040 [default=false]; + optional string base_class = 1041; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 2d609f6dd6..415409f880 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -628,6 +628,7 @@ template<> const char *proto_enum_to_string(enums::UpdateC } } #endif + bool HelloRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { case 2: { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 8b3f7a7b2a..14a1f3f353 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -253,6 +253,27 @@ enum UpdateCommand : uint32_t { } // namespace enums +class InfoResponseProtoMessage : public ProtoMessage { + public: + ~InfoResponseProtoMessage() override = default; + std::string object_id{}; + uint32_t key{0}; + std::string name{}; + std::string unique_id{}; + bool disabled_by_default{false}; + std::string icon{}; + enums::EntityCategory entity_category{}; + + protected: +}; + +class StateResponseProtoMessage : public ProtoMessage { + public: + ~StateResponseProtoMessage() override = default; + uint32_t key{0}; + + protected: +}; class HelloRequest : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 1; @@ -484,22 +505,15 @@ class SubscribeStatesRequest : public ProtoMessage { protected: }; -class ListEntitiesBinarySensorResponse : public ProtoMessage { +class ListEntitiesBinarySensorResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 12; static constexpr uint16_t ESTIMATED_SIZE = 56; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_binary_sensor_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; std::string device_class{}; bool is_status_binary_sensor{false}; - bool disabled_by_default{false}; - std::string icon{}; - enums::EntityCategory entity_category{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -511,14 +525,13 @@ class ListEntitiesBinarySensorResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BinarySensorStateResponse : public ProtoMessage { +class BinarySensorStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 21; static constexpr uint16_t ESTIMATED_SIZE = 9; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "binary_sensor_state_response"; } #endif - uint32_t key{0}; bool state{false}; bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; @@ -531,24 +544,17 @@ class BinarySensorStateResponse : public ProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesCoverResponse : public ProtoMessage { +class ListEntitiesCoverResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 13; static constexpr uint16_t ESTIMATED_SIZE = 62; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_cover_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; bool assumed_state{false}; bool supports_position{false}; bool supports_tilt{false}; std::string device_class{}; - bool disabled_by_default{false}; - std::string icon{}; - enums::EntityCategory entity_category{}; bool supports_stop{false}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -561,14 +567,13 @@ class ListEntitiesCoverResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class CoverStateResponse : public ProtoMessage { +class CoverStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 22; static constexpr uint16_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "cover_state_response"; } #endif - uint32_t key{0}; enums::LegacyCoverState legacy_state{}; float position{0.0f}; float tilt{0.0f}; @@ -608,24 +613,17 @@ class CoverCommandRequest : public ProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesFanResponse : public ProtoMessage { +class ListEntitiesFanResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 14; static constexpr uint16_t ESTIMATED_SIZE = 73; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_fan_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; bool supports_oscillation{false}; bool supports_speed{false}; bool supports_direction{false}; int32_t supported_speed_count{0}; - bool disabled_by_default{false}; - std::string icon{}; - enums::EntityCategory entity_category{}; std::vector supported_preset_modes{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -638,14 +636,13 @@ class ListEntitiesFanResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class FanStateResponse : public ProtoMessage { +class FanStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 23; static constexpr uint16_t ESTIMATED_SIZE = 26; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "fan_state_response"; } #endif - uint32_t key{0}; bool state{false}; bool oscillating{false}; enums::FanSpeed speed{}; @@ -694,17 +691,13 @@ class FanCommandRequest : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesLightResponse : public ProtoMessage { +class ListEntitiesLightResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 15; static constexpr uint16_t ESTIMATED_SIZE = 85; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_light_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; std::vector supported_color_modes{}; bool legacy_supports_brightness{false}; bool legacy_supports_rgb{false}; @@ -713,9 +706,6 @@ class ListEntitiesLightResponse : public ProtoMessage { float min_mireds{0.0f}; float max_mireds{0.0f}; std::vector effects{}; - bool disabled_by_default{false}; - std::string icon{}; - enums::EntityCategory entity_category{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -727,14 +717,13 @@ class ListEntitiesLightResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class LightStateResponse : public ProtoMessage { +class LightStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 24; static constexpr uint16_t ESTIMATED_SIZE = 63; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "light_state_response"; } #endif - uint32_t key{0}; bool state{false}; float brightness{0.0f}; enums::ColorMode color_mode{}; @@ -803,26 +792,19 @@ class LightCommandRequest : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesSensorResponse : public ProtoMessage { +class ListEntitiesSensorResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 16; static constexpr uint16_t ESTIMATED_SIZE = 73; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_sensor_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; std::string unit_of_measurement{}; int32_t accuracy_decimals{0}; bool force_update{false}; std::string device_class{}; enums::SensorStateClass state_class{}; enums::SensorLastResetType legacy_last_reset_type{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -834,14 +816,13 @@ class ListEntitiesSensorResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class SensorStateResponse : public ProtoMessage { +class SensorStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 25; static constexpr uint16_t ESTIMATED_SIZE = 12; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "sensor_state_response"; } #endif - uint32_t key{0}; float state{0.0f}; bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; @@ -854,21 +835,14 @@ class SensorStateResponse : public ProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesSwitchResponse : public ProtoMessage { +class ListEntitiesSwitchResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 17; static constexpr uint16_t ESTIMATED_SIZE = 56; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_switch_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; bool assumed_state{false}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; std::string device_class{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -881,14 +855,13 @@ class ListEntitiesSwitchResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class SwitchStateResponse : public ProtoMessage { +class SwitchStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 26; static constexpr uint16_t ESTIMATED_SIZE = 7; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "switch_state_response"; } #endif - uint32_t key{0}; bool state{false}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -919,20 +892,13 @@ class SwitchCommandRequest : public ProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesTextSensorResponse : public ProtoMessage { +class ListEntitiesTextSensorResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 18; static constexpr uint16_t ESTIMATED_SIZE = 54; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_text_sensor_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; std::string device_class{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -945,14 +911,13 @@ class ListEntitiesTextSensorResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class TextSensorStateResponse : public ProtoMessage { +class TextSensorStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 27; static constexpr uint16_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "text_sensor_state_response"; } #endif - uint32_t key{0}; std::string state{}; bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; @@ -1249,20 +1214,13 @@ class ExecuteServiceRequest : public ProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class ListEntitiesCameraResponse : public ProtoMessage { +class ListEntitiesCameraResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 43; static constexpr uint16_t ESTIMATED_SIZE = 45; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_camera_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - bool disabled_by_default{false}; - std::string icon{}; - enums::EntityCategory entity_category{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1313,17 +1271,13 @@ class CameraImageRequest : public ProtoMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesClimateResponse : public ProtoMessage { +class ListEntitiesClimateResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 46; static constexpr uint16_t ESTIMATED_SIZE = 151; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_climate_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; bool supports_current_temperature{false}; bool supports_two_point_target_temperature{false}; std::vector supported_modes{}; @@ -1337,9 +1291,6 @@ class ListEntitiesClimateResponse : public ProtoMessage { std::vector supported_custom_fan_modes{}; std::vector supported_presets{}; std::vector supported_custom_presets{}; - bool disabled_by_default{false}; - std::string icon{}; - enums::EntityCategory entity_category{}; float visual_current_temperature_step{0.0f}; bool supports_current_humidity{false}; bool supports_target_humidity{false}; @@ -1356,14 +1307,13 @@ class ListEntitiesClimateResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ClimateStateResponse : public ProtoMessage { +class ClimateStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 47; static constexpr uint16_t ESTIMATED_SIZE = 65; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "climate_state_response"; } #endif - uint32_t key{0}; enums::ClimateMode mode{}; float current_temperature{0.0f}; float target_temperature{0.0f}; @@ -1430,23 +1380,16 @@ class ClimateCommandRequest : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesNumberResponse : public ProtoMessage { +class ListEntitiesNumberResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 49; static constexpr uint16_t ESTIMATED_SIZE = 80; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_number_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; float min_value{0.0f}; float max_value{0.0f}; float step{0.0f}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; std::string unit_of_measurement{}; enums::NumberMode mode{}; std::string device_class{}; @@ -1461,14 +1404,13 @@ class ListEntitiesNumberResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class NumberStateResponse : public ProtoMessage { +class NumberStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 50; static constexpr uint16_t ESTIMATED_SIZE = 12; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "number_state_response"; } #endif - uint32_t key{0}; float state{0.0f}; bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; @@ -1499,21 +1441,14 @@ class NumberCommandRequest : public ProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; }; -class ListEntitiesSelectResponse : public ProtoMessage { +class ListEntitiesSelectResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 52; static constexpr uint16_t ESTIMATED_SIZE = 63; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_select_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; std::vector options{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1525,14 +1460,13 @@ class ListEntitiesSelectResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class SelectStateResponse : public ProtoMessage { +class SelectStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 53; static constexpr uint16_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "select_state_response"; } #endif - uint32_t key{0}; std::string state{}; bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; @@ -1565,23 +1499,16 @@ class SelectCommandRequest : public ProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class ListEntitiesSirenResponse : public ProtoMessage { +class ListEntitiesSirenResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 55; static constexpr uint16_t ESTIMATED_SIZE = 67; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_siren_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; std::vector tones{}; bool supports_duration{false}; bool supports_volume{false}; - enums::EntityCategory entity_category{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1593,14 +1520,13 @@ class ListEntitiesSirenResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class SirenStateResponse : public ProtoMessage { +class SirenStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 56; static constexpr uint16_t ESTIMATED_SIZE = 7; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "siren_state_response"; } #endif - uint32_t key{0}; bool state{false}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -1639,20 +1565,13 @@ class SirenCommandRequest : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesLockResponse : public ProtoMessage { +class ListEntitiesLockResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 58; static constexpr uint16_t ESTIMATED_SIZE = 60; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_lock_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; bool assumed_state{false}; bool supports_open{false}; bool requires_code{false}; @@ -1668,14 +1587,13 @@ class ListEntitiesLockResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class LockStateResponse : public ProtoMessage { +class LockStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 59; static constexpr uint16_t ESTIMATED_SIZE = 7; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "lock_state_response"; } #endif - uint32_t key{0}; enums::LockState state{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -1709,20 +1627,13 @@ class LockCommandRequest : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesButtonResponse : public ProtoMessage { +class ListEntitiesButtonResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 61; static constexpr uint16_t ESTIMATED_SIZE = 54; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_button_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; std::string device_class{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -1769,20 +1680,13 @@ class MediaPlayerSupportedFormat : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesMediaPlayerResponse : public ProtoMessage { +class ListEntitiesMediaPlayerResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 63; static constexpr uint16_t ESTIMATED_SIZE = 81; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_media_player_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; bool supports_pause{false}; std::vector supported_formats{}; void encode(ProtoWriteBuffer buffer) const override; @@ -1796,14 +1700,13 @@ class ListEntitiesMediaPlayerResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class MediaPlayerStateResponse : public ProtoMessage { +class MediaPlayerStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 64; static constexpr uint16_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "media_player_state_response"; } #endif - uint32_t key{0}; enums::MediaPlayerState state{}; float volume{0.0f}; bool muted{false}; @@ -2653,20 +2556,13 @@ class VoiceAssistantSetConfiguration : public ProtoMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class ListEntitiesAlarmControlPanelResponse : public ProtoMessage { +class ListEntitiesAlarmControlPanelResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 94; static constexpr uint16_t ESTIMATED_SIZE = 53; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_alarm_control_panel_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; uint32_t supported_features{0}; bool requires_code{false}; bool requires_code_to_arm{false}; @@ -2681,14 +2577,13 @@ class ListEntitiesAlarmControlPanelResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class AlarmControlPanelStateResponse : public ProtoMessage { +class AlarmControlPanelStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 95; static constexpr uint16_t ESTIMATED_SIZE = 7; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "alarm_control_panel_state_response"; } #endif - uint32_t key{0}; enums::AlarmControlPanelState state{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -2721,20 +2616,13 @@ class AlarmControlPanelCommandRequest : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesTextResponse : public ProtoMessage { +class ListEntitiesTextResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 97; static constexpr uint16_t ESTIMATED_SIZE = 64; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_text_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; uint32_t min_length{0}; uint32_t max_length{0}; std::string pattern{}; @@ -2750,14 +2638,13 @@ class ListEntitiesTextResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class TextStateResponse : public ProtoMessage { +class TextStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 98; static constexpr uint16_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "text_state_response"; } #endif - uint32_t key{0}; std::string state{}; bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; @@ -2790,20 +2677,13 @@ class TextCommandRequest : public ProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class ListEntitiesDateResponse : public ProtoMessage { +class ListEntitiesDateResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 100; static constexpr uint16_t ESTIMATED_SIZE = 45; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_date_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2815,14 +2695,13 @@ class ListEntitiesDateResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class DateStateResponse : public ProtoMessage { +class DateStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 101; static constexpr uint16_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "date_state_response"; } #endif - uint32_t key{0}; bool missing_state{false}; uint32_t year{0}; uint32_t month{0}; @@ -2858,20 +2737,13 @@ class DateCommandRequest : public ProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesTimeResponse : public ProtoMessage { +class ListEntitiesTimeResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 103; static constexpr uint16_t ESTIMATED_SIZE = 45; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_time_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2883,14 +2755,13 @@ class ListEntitiesTimeResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class TimeStateResponse : public ProtoMessage { +class TimeStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 104; static constexpr uint16_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "time_state_response"; } #endif - uint32_t key{0}; bool missing_state{false}; uint32_t hour{0}; uint32_t minute{0}; @@ -2926,20 +2797,13 @@ class TimeCommandRequest : public ProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesEventResponse : public ProtoMessage { +class ListEntitiesEventResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 107; static constexpr uint16_t ESTIMATED_SIZE = 72; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_event_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; std::string device_class{}; std::vector event_types{}; void encode(ProtoWriteBuffer buffer) const override; @@ -2953,14 +2817,13 @@ class ListEntitiesEventResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class EventResponse : public ProtoMessage { +class EventResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 108; static constexpr uint16_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "event_response"; } #endif - uint32_t key{0}; std::string event_type{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -2972,20 +2835,13 @@ class EventResponse : public ProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class ListEntitiesValveResponse : public ProtoMessage { +class ListEntitiesValveResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 109; static constexpr uint16_t ESTIMATED_SIZE = 60; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_valve_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; std::string device_class{}; bool assumed_state{false}; bool supports_position{false}; @@ -3001,14 +2857,13 @@ class ListEntitiesValveResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ValveStateResponse : public ProtoMessage { +class ValveStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 110; static constexpr uint16_t ESTIMATED_SIZE = 12; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "valve_state_response"; } #endif - uint32_t key{0}; float position{0.0f}; enums::ValveOperation current_operation{}; void encode(ProtoWriteBuffer buffer) const override; @@ -3042,20 +2897,13 @@ class ValveCommandRequest : public ProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ListEntitiesDateTimeResponse : public ProtoMessage { +class ListEntitiesDateTimeResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 112; static constexpr uint16_t ESTIMATED_SIZE = 45; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_date_time_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -3067,14 +2915,13 @@ class ListEntitiesDateTimeResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class DateTimeStateResponse : public ProtoMessage { +class DateTimeStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 113; static constexpr uint16_t ESTIMATED_SIZE = 12; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "date_time_state_response"; } #endif - uint32_t key{0}; bool missing_state{false}; uint32_t epoch_seconds{0}; void encode(ProtoWriteBuffer buffer) const override; @@ -3105,20 +2952,13 @@ class DateTimeCommandRequest : public ProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; }; -class ListEntitiesUpdateResponse : public ProtoMessage { +class ListEntitiesUpdateResponse : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 116; static constexpr uint16_t ESTIMATED_SIZE = 54; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "list_entities_update_response"; } #endif - std::string object_id{}; - uint32_t key{0}; - std::string name{}; - std::string unique_id{}; - std::string icon{}; - bool disabled_by_default{false}; - enums::EntityCategory entity_category{}; std::string device_class{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -3131,14 +2971,13 @@ class ListEntitiesUpdateResponse : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class UpdateStateResponse : public ProtoMessage { +class UpdateStateResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 117; static constexpr uint16_t ESTIMATED_SIZE = 61; #ifdef HAS_PROTO_MESSAGE_DUMP static constexpr const char *message_name() { return "update_state_response"; } #endif - uint32_t key{0}; bool missing_state{false}; bool in_progress{false}; bool has_progress{false}; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index d634be98c4..24b6bef843 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -848,7 +848,10 @@ def calculate_message_estimated_size(desc: descriptor.DescriptorProto) -> int: return total_size -def build_message_type(desc: descriptor.DescriptorProto) -> tuple[str, str]: +def build_message_type( + desc: descriptor.DescriptorProto, + base_class_fields: dict[str, list[descriptor.FieldDescriptorProto]] = None, +) -> tuple[str, str]: public_content: list[str] = [] protected_content: list[str] = [] decode_varint: list[str] = [] @@ -859,6 +862,12 @@ def build_message_type(desc: descriptor.DescriptorProto) -> tuple[str, str]: dump: list[str] = [] size_calc: list[str] = [] + # Check if this message has a base class + base_class = get_base_class(desc) + common_field_names = set() + if base_class and base_class_fields and base_class in base_class_fields: + common_field_names = {f.name for f in base_class_fields[base_class]} + # Get message ID if it's a service message message_id: int | None = get_opt(desc, pb.id) @@ -886,8 +895,14 @@ def build_message_type(desc: descriptor.DescriptorProto) -> tuple[str, str]: ti = RepeatedTypeInfo(field) else: ti = TYPE_INFO[field.type](field) - protected_content.extend(ti.protected_content) - public_content.extend(ti.public_content) + + # Skip field declarations for fields that are in the base class + # but include their encode/decode logic + if field.name not in common_field_names: + protected_content.extend(ti.protected_content) + public_content.extend(ti.public_content) + + # Always include encode/decode logic for all fields encode.append(ti.encode_content) size_calc.append(ti.get_size_calculation(f"this->{ti.field_name}")) @@ -1001,7 +1016,10 @@ def build_message_type(desc: descriptor.DescriptorProto) -> tuple[str, str]: prot += "#endif\n" public_content.append(prot) - out = f"class {desc.name} : public ProtoMessage {{\n" + if base_class: + out = f"class {desc.name} : public {base_class} {{\n" + else: + out = f"class {desc.name} : public ProtoMessage {{\n" out += " public:\n" out += indent("\n".join(public_content)) + "\n" out += "\n" @@ -1033,6 +1051,132 @@ def get_opt( return desc.options.Extensions[opt] +def get_base_class(desc: descriptor.DescriptorProto) -> str | None: + """Get the base_class option from a message descriptor.""" + if not desc.options.HasExtension(pb.base_class): + return None + return desc.options.Extensions[pb.base_class] + + +def collect_messages_by_base_class( + messages: list[descriptor.DescriptorProto], +) -> dict[str, list[descriptor.DescriptorProto]]: + """Group messages by their base_class option.""" + base_class_groups = {} + + for msg in messages: + base_class = get_base_class(msg) + if base_class: + if base_class not in base_class_groups: + base_class_groups[base_class] = [] + base_class_groups[base_class].append(msg) + + return base_class_groups + + +def find_common_fields( + messages: list[descriptor.DescriptorProto], +) -> list[descriptor.FieldDescriptorProto]: + """Find fields that are common to all messages in the list.""" + if not messages: + return [] + + # Start with fields from the first message + first_msg_fields = {field.name: field for field in messages[0].field} + common_fields = [] + + # Check each field to see if it exists in all messages with same type + # Field numbers can vary between messages - derived classes handle the mapping + for field_name, field in first_msg_fields.items(): + is_common = True + + for msg in messages[1:]: + found = False + for other_field in msg.field: + if ( + other_field.name == field_name + and other_field.type == field.type + and other_field.label == field.label + ): + found = True + break + + if not found: + is_common = False + break + + if is_common: + common_fields.append(field) + + # Sort by field number to maintain order + common_fields.sort(key=lambda f: f.number) + return common_fields + + +def build_base_class( + base_class_name: str, + common_fields: list[descriptor.FieldDescriptorProto], +) -> tuple[str, str]: + """Build the base class definition and implementation.""" + public_content = [] + protected_content = [] + + # For base classes, we only declare the fields but don't handle encode/decode + # The derived classes will handle encoding/decoding with their specific field numbers + for field in common_fields: + if field.label == 3: # repeated + ti = RepeatedTypeInfo(field) + else: + ti = TYPE_INFO[field.type](field) + + # Only add field declarations, not encode/decode logic + protected_content.extend(ti.protected_content) + public_content.extend(ti.public_content) + + # Build header + out = f"class {base_class_name} : public ProtoMessage {{\n" + out += " public:\n" + + # Add destructor with override + public_content.insert(0, f"~{base_class_name}() override = default;") + + # Base classes don't implement encode/decode/calculate_size + # Derived classes handle these with their specific field numbers + cpp = "" + + out += indent("\n".join(public_content)) + "\n" + out += "\n" + out += " protected:\n" + out += indent("\n".join(protected_content)) + if protected_content: + out += "\n" + out += "};\n" + + # No implementation needed for base classes + + return out, cpp + + +def generate_base_classes( + base_class_groups: dict[str, list[descriptor.DescriptorProto]], +) -> tuple[str, str]: + """Generate all base classes.""" + all_headers = [] + all_cpp = [] + + for base_class_name, messages in base_class_groups.items(): + # Find common fields + common_fields = find_common_fields(messages) + + if common_fields: + # Generate base class + header, cpp = build_base_class(base_class_name, common_fields) + all_headers.append(header) + all_cpp.append(cpp) + + return "\n".join(all_headers), "\n".join(all_cpp) + + def build_service_message_type( mt: descriptor.DescriptorProto, ) -> tuple[str, str] | None: @@ -1134,8 +1278,25 @@ def main() -> None: mt = file.message_type + # Collect messages by base class + base_class_groups = collect_messages_by_base_class(mt) + + # Find common fields for each base class + base_class_fields = {} + for base_class_name, messages in base_class_groups.items(): + common_fields = find_common_fields(messages) + if common_fields: + base_class_fields[base_class_name] = common_fields + + # Generate base classes + if base_class_fields: + base_headers, base_cpp = generate_base_classes(base_class_groups) + content += base_headers + cpp += base_cpp + + # Generate message types with base class information for m in mt: - s, c = build_message_type(m) + s, c = build_message_type(m, base_class_fields) content += s cpp += c From 20d7ba5d7c782d814a7b186e7920e178e1ac50f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Jun 2025 16:33:38 -0500 Subject: [PATCH 24/27] Reduce Component blocking threshold memory usage by 2 bytes per component (#9081) --- esphome/core/component.cpp | 13 ++++++++++--- esphome/core/component.h | 4 ++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index dae99a0d22..03c44599e2 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -1,6 +1,7 @@ #include "esphome/core/component.h" #include +#include #include #include "esphome/core/application.h" #include "esphome/core/hal.h" @@ -41,8 +42,8 @@ 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 uint32_t WARN_IF_BLOCKING_OVER_MS = 50U; ///< Initial blocking time allowed without warning -const uint32_t WARN_IF_BLOCKING_INCREMENT_MS = 10U; ///< How long the blocking time must be larger to warn again +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 uint32_t global_state = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -122,7 +123,13 @@ const char *Component::get_component_source() const { } bool Component::should_warn_of_blocking(uint32_t blocking_time) { if (blocking_time > this->warn_if_blocking_over_) { - this->warn_if_blocking_over_ = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS; + // Prevent overflow when adding increment - if we're about to overflow, just max out + if (blocking_time + WARN_IF_BLOCKING_INCREMENT_MS < blocking_time || + blocking_time + WARN_IF_BLOCKING_INCREMENT_MS > std::numeric_limits::max()) { + this->warn_if_blocking_over_ = std::numeric_limits::max(); + } else { + this->warn_if_blocking_over_ = static_cast(blocking_time + WARN_IF_BLOCKING_INCREMENT_MS); + } return true; } return false; diff --git a/esphome/core/component.h b/esphome/core/component.h index 7ad4a5e496..d05a965034 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -65,7 +65,7 @@ extern const uint8_t STATUS_LED_ERROR; enum class RetryResult { DONE, RETRY }; -extern const uint32_t WARN_IF_BLOCKING_OVER_MS; +extern const uint16_t WARN_IF_BLOCKING_OVER_MS; class Component { public: @@ -318,7 +318,7 @@ class Component { uint8_t component_state_{0x00}; float setup_priority_override_{NAN}; const char *component_source_{nullptr}; - uint32_t warn_if_blocking_over_{WARN_IF_BLOCKING_OVER_MS}; + uint16_t warn_if_blocking_over_{WARN_IF_BLOCKING_OVER_MS}; ///< Warn if blocked for this many ms (max 65.5s) std::string error_message_{}; }; From 47d8048a629632d9a4092796a34847c413e5b58a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 16 Jun 2025 10:07:07 +1200 Subject: [PATCH 25/27] Bump version to 2025.6.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 5c8229ba12..d362e8f1cf 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.0b1 +PROJECT_NUMBER = 2025.6.0b2 # 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 cffffca8c0..889e040ad3 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1,6 +1,6 @@ """Constants used by esphome.""" -__version__ = "2025.6.0b1" +__version__ = "2025.6.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 6bad2765898b828d405599866e63450d0c109643 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Jun 2025 10:42:01 -0500 Subject: [PATCH 26/27] Bump aioesphomeapi from 32.2.1 to 32.2.3 (#9091) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d4bd0b7543..682f9dbe60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.8.1 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==32.2.1 +aioesphomeapi==32.2.3 zeroconf==0.147.0 puremagic==1.29 ruamel.yaml==0.18.14 # dashboard_import From 2a81efda0bdcd2f469598b2196f1606254607f17 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 16 Jun 2025 12:55:23 +1200 Subject: [PATCH 27/27] Remove ``std::`` prefix as not all platforms have access yet. (#9095) --- esphome/components/sensor/sensor.cpp | 2 +- esphome/components/text_sensor/text_sensor.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 3be0df9963..6d6cff0400 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -54,7 +54,7 @@ void Sensor::publish_state(float state) { void Sensor::add_on_state_callback(std::function &&callback) { this->callback_.add(std::move(callback)); } void Sensor::add_on_raw_state_callback(std::function &&callback) { if (!this->raw_callback_) { - this->raw_callback_ = std::make_unique>(); + this->raw_callback_ = make_unique>(); } this->raw_callback_->add(std::move(callback)); } diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 91cb320782..c57e0ffefb 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -56,7 +56,7 @@ void TextSensor::add_on_state_callback(std::function callback } void TextSensor::add_on_raw_state_callback(std::function callback) { if (!this->raw_callback_) { - this->raw_callback_ = std::make_unique>(); + this->raw_callback_ = make_unique>(); } this->raw_callback_->add(std::move(callback)); }