From c4fac1a2ae134167624aaf33fcac9296f3110b50 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 7 Jul 2025 23:21:14 +0200 Subject: [PATCH 01/51] [nextion] Optimize component memory usage with bitfield state management (#9373) --- .../binary_sensor/nextion_binarysensor.cpp | 2 +- .../components/nextion/nextion_component.cpp | 56 +++++++-------- .../components/nextion/nextion_component.h | 71 ++++++++++++++----- .../nextion/sensor/nextion_sensor.cpp | 2 +- .../nextion/switch/nextion_switch.cpp | 2 +- .../text_sensor/nextion_textsensor.cpp | 2 +- 6 files changed, 85 insertions(+), 50 deletions(-) diff --git a/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp b/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp index b6d4cc3f23..3628ac2f63 100644 --- a/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp +++ b/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp @@ -44,7 +44,7 @@ void NextionBinarySensor::set_state(bool state, bool publish, bool send_to_nexti return; if (send_to_nextion) { - if (this->nextion_->is_sleeping() || !this->visible_) { + if (this->nextion_->is_sleeping() || !this->component_flags_.visible) { this->needs_to_send_update_ = true; } else { this->needs_to_send_update_ = false; diff --git a/esphome/components/nextion/nextion_component.cpp b/esphome/components/nextion/nextion_component.cpp index cfb4e3600c..32929d6845 100644 --- a/esphome/components/nextion/nextion_component.cpp +++ b/esphome/components/nextion/nextion_component.cpp @@ -8,8 +8,8 @@ void NextionComponent::set_background_color(Color bco) { return; // This is a variable. no need to set color } this->bco_ = bco; - this->bco_needs_update_ = true; - this->bco_is_set_ = true; + this->component_flags_.bco_needs_update = true; + this->component_flags_.bco_is_set = true; this->update_component_settings(); } @@ -19,8 +19,8 @@ void NextionComponent::set_background_pressed_color(Color bco2) { } this->bco2_ = bco2; - this->bco2_needs_update_ = true; - this->bco2_is_set_ = true; + this->component_flags_.bco2_needs_update = true; + this->component_flags_.bco2_is_set = true; this->update_component_settings(); } @@ -29,8 +29,8 @@ void NextionComponent::set_foreground_color(Color pco) { return; // This is a variable. no need to set color } this->pco_ = pco; - this->pco_needs_update_ = true; - this->pco_is_set_ = true; + this->component_flags_.pco_needs_update = true; + this->component_flags_.pco_is_set = true; this->update_component_settings(); } @@ -39,8 +39,8 @@ void NextionComponent::set_foreground_pressed_color(Color pco2) { return; // This is a variable. no need to set color } this->pco2_ = pco2; - this->pco2_needs_update_ = true; - this->pco2_is_set_ = true; + this->component_flags_.pco2_needs_update = true; + this->component_flags_.pco2_is_set = true; this->update_component_settings(); } @@ -49,8 +49,8 @@ void NextionComponent::set_font_id(uint8_t font_id) { return; // This is a variable. no need to set color } this->font_id_ = font_id; - this->font_id_needs_update_ = true; - this->font_id_is_set_ = true; + this->component_flags_.font_id_needs_update = true; + this->component_flags_.font_id_is_set = true; this->update_component_settings(); } @@ -58,20 +58,20 @@ void NextionComponent::set_visible(bool visible) { if (this->variable_name_ == this->variable_name_to_send_) { return; // This is a variable. no need to set color } - this->visible_ = visible; - this->visible_needs_update_ = true; - this->visible_is_set_ = true; + this->component_flags_.visible = visible; + this->component_flags_.visible_needs_update = true; + this->component_flags_.visible_is_set = true; this->update_component_settings(); } void NextionComponent::update_component_settings(bool force_update) { - if (this->nextion_->is_sleeping() || !this->nextion_->is_setup() || !this->visible_is_set_ || - (!this->visible_needs_update_ && !this->visible_)) { + if (this->nextion_->is_sleeping() || !this->nextion_->is_setup() || !this->component_flags_.visible_is_set || + (!this->component_flags_.visible_needs_update && !this->component_flags_.visible)) { this->needs_to_send_update_ = true; return; } - if (this->visible_needs_update_ || (force_update && this->visible_is_set_)) { + if (this->component_flags_.visible_needs_update || (force_update && this->component_flags_.visible_is_set)) { std::string name_to_send = this->variable_name_; size_t pos = name_to_send.find_last_of('.'); @@ -79,9 +79,9 @@ void NextionComponent::update_component_settings(bool force_update) { name_to_send = name_to_send.substr(pos + 1); } - this->visible_needs_update_ = false; + this->component_flags_.visible_needs_update = false; - if (this->visible_) { + if (this->component_flags_.visible) { this->nextion_->show_component(name_to_send.c_str()); this->send_state_to_nextion(); } else { @@ -90,26 +90,26 @@ void NextionComponent::update_component_settings(bool force_update) { } } - if (this->bco_needs_update_ || (force_update && this->bco2_is_set_)) { + if (this->component_flags_.bco_needs_update || (force_update && this->component_flags_.bco2_is_set)) { this->nextion_->set_component_background_color(this->variable_name_.c_str(), this->bco_); - this->bco_needs_update_ = false; + this->component_flags_.bco_needs_update = false; } - if (this->bco2_needs_update_ || (force_update && this->bco2_is_set_)) { + if (this->component_flags_.bco2_needs_update || (force_update && this->component_flags_.bco2_is_set)) { this->nextion_->set_component_pressed_background_color(this->variable_name_.c_str(), this->bco2_); - this->bco2_needs_update_ = false; + this->component_flags_.bco2_needs_update = false; } - if (this->pco_needs_update_ || (force_update && this->pco_is_set_)) { + if (this->component_flags_.pco_needs_update || (force_update && this->component_flags_.pco_is_set)) { this->nextion_->set_component_foreground_color(this->variable_name_.c_str(), this->pco_); - this->pco_needs_update_ = false; + this->component_flags_.pco_needs_update = false; } - if (this->pco2_needs_update_ || (force_update && this->pco2_is_set_)) { + if (this->component_flags_.pco2_needs_update || (force_update && this->component_flags_.pco2_is_set)) { this->nextion_->set_component_pressed_foreground_color(this->variable_name_.c_str(), this->pco2_); - this->pco2_needs_update_ = false; + this->component_flags_.pco2_needs_update = false; } - if (this->font_id_needs_update_ || (force_update && this->font_id_is_set_)) { + if (this->component_flags_.font_id_needs_update || (force_update && this->component_flags_.font_id_is_set)) { this->nextion_->set_component_font(this->variable_name_.c_str(), this->font_id_); - this->font_id_needs_update_ = false; + this->component_flags_.font_id_needs_update = false; } } } // namespace nextion diff --git a/esphome/components/nextion/nextion_component.h b/esphome/components/nextion/nextion_component.h index 2f3c4f3c16..add9e11cf1 100644 --- a/esphome/components/nextion/nextion_component.h +++ b/esphome/components/nextion/nextion_component.h @@ -21,29 +21,64 @@ class NextionComponent : public NextionComponentBase { void set_visible(bool visible); protected: + /** + * @brief Constructor initializes component state with visible=true (default state) + */ + NextionComponent() { + component_flags_ = {}; // Zero-initialize all state + component_flags_.visible = 1; // Set default visibility to true + } + NextionBase *nextion_; - bool bco_needs_update_ = false; - bool bco_is_set_ = false; - Color bco_; - bool bco2_needs_update_ = false; - bool bco2_is_set_ = false; - Color bco2_; - bool pco_needs_update_ = false; - bool pco_is_set_ = false; - Color pco_; - bool pco2_needs_update_ = false; - bool pco2_is_set_ = false; - Color pco2_; + // Color and styling properties + Color bco_; // Background color + Color bco2_; // Pressed background color + Color pco_; // Foreground color + Color pco2_; // Pressed foreground color uint8_t font_id_ = 0; - bool font_id_needs_update_ = false; - bool font_id_is_set_ = false; - bool visible_ = true; - bool visible_needs_update_ = false; - bool visible_is_set_ = false; + /** + * @brief Component state management using compact bitfield structure + * + * Stores all component state flags and properties in a single 16-bit bitfield + * for efficient memory usage and improved cache locality. + * + * Each component property maintains two state flags: + * - needs_update: Indicates the property requires synchronization with the display + * - is_set: Tracks whether the property has been explicitly configured + * + * The visible field stores both the update flags and the actual visibility state. + */ + struct ComponentState { + // Background color flags + uint16_t bco_needs_update : 1; + uint16_t bco_is_set : 1; - // void send_state_to_nextion() = 0; + // Pressed background color flags + uint16_t bco2_needs_update : 1; + uint16_t bco2_is_set : 1; + + // Foreground color flags + uint16_t pco_needs_update : 1; + uint16_t pco_is_set : 1; + + // Pressed foreground color flags + uint16_t pco2_needs_update : 1; + uint16_t pco2_is_set : 1; + + // Font ID flags + uint16_t font_id_needs_update : 1; + uint16_t font_id_is_set : 1; + + // Visibility flags + uint16_t visible_needs_update : 1; + uint16_t visible_is_set : 1; + uint16_t visible : 1; // Actual visibility state + + // Reserved bits for future expansion + uint16_t reserved : 3; + } component_flags_; }; } // namespace nextion } // namespace esphome diff --git a/esphome/components/nextion/sensor/nextion_sensor.cpp b/esphome/components/nextion/sensor/nextion_sensor.cpp index 0ed9da95d4..03b7261239 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.cpp +++ b/esphome/components/nextion/sensor/nextion_sensor.cpp @@ -53,7 +53,7 @@ void NextionSensor::set_state(float state, bool publish, bool send_to_nextion) { if (this->wave_chan_id_ == UINT8_MAX) { if (send_to_nextion) { - if (this->nextion_->is_sleeping() || !this->visible_) { + if (this->nextion_->is_sleeping() || !this->component_flags_.visible) { this->needs_to_send_update_ = true; } else { this->needs_to_send_update_ = false; diff --git a/esphome/components/nextion/switch/nextion_switch.cpp b/esphome/components/nextion/switch/nextion_switch.cpp index fe71182496..21636f2bfa 100644 --- a/esphome/components/nextion/switch/nextion_switch.cpp +++ b/esphome/components/nextion/switch/nextion_switch.cpp @@ -28,7 +28,7 @@ void NextionSwitch::set_state(bool state, bool publish, bool send_to_nextion) { return; if (send_to_nextion) { - if (this->nextion_->is_sleeping() || !this->visible_) { + if (this->nextion_->is_sleeping() || !this->component_flags_.visible) { this->needs_to_send_update_ = true; } else { this->needs_to_send_update_ = false; diff --git a/esphome/components/nextion/text_sensor/nextion_textsensor.cpp b/esphome/components/nextion/text_sensor/nextion_textsensor.cpp index e08cbb02ca..9b6deeda87 100644 --- a/esphome/components/nextion/text_sensor/nextion_textsensor.cpp +++ b/esphome/components/nextion/text_sensor/nextion_textsensor.cpp @@ -26,7 +26,7 @@ void NextionTextSensor::set_state(const std::string &state, bool publish, bool s return; if (send_to_nextion) { - if (this->nextion_->is_sleeping() || !this->visible_) { + if (this->nextion_->is_sleeping() || !this->component_flags_.visible) { this->needs_to_send_update_ = true; } else { this->nextion_->add_no_result_to_queue_with_set(this, state); From fb357b896581d655246fd73bf8c7ccb958bd28f4 Mon Sep 17 00:00:00 2001 From: Steffen Arntz Date: Mon, 7 Jul 2025 23:25:11 +0200 Subject: [PATCH 02/51] Fix brightness setting not working on SSD1305 128x32 OLEDs (#9376) --- esphome/components/ssd1306_base/ssd1306_base.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ssd1306_base/ssd1306_base.cpp b/esphome/components/ssd1306_base/ssd1306_base.cpp index 0547a77184..1f039cff78 100644 --- a/esphome/components/ssd1306_base/ssd1306_base.cpp +++ b/esphome/components/ssd1306_base/ssd1306_base.cpp @@ -224,7 +224,7 @@ bool SSD1306::is_sh1106_() const { } bool SSD1306::is_sh1107_() const { return this->model_ == SH1107_MODEL_128_64 || this->model_ == SH1107_MODEL_128_128; } bool SSD1306::is_ssd1305_() const { - return this->model_ == SSD1305_MODEL_128_64 || this->model_ == SSD1305_MODEL_128_64; + return this->model_ == SSD1305_MODEL_128_64 || this->model_ == SSD1305_MODEL_128_32; } void SSD1306::update() { this->do_update_(); From ee8ee4e6464c9b1d6a41358b50e0c9ce325287b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Jul 2025 17:00:03 -0500 Subject: [PATCH 03/51] Optimize logger callback API by including message length parameter (#9368) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/api/api_connection.cpp | 7 +++-- esphome/components/api/api_connection.h | 2 +- esphome/components/api/api_server.cpp | 25 ++++++++--------- esphome/components/logger/logger.cpp | 28 +++++++++++++++++--- esphome/components/logger/logger.h | 8 +++--- esphome/components/mqtt/mqtt_client.cpp | 17 ++++++------ esphome/components/syslog/esphome_syslog.cpp | 8 +++--- esphome/components/syslog/esphome_syslog.h | 2 +- esphome/components/web_server/web_server.cpp | 3 ++- 9 files changed, 63 insertions(+), 37 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 01f4552842..779784e787 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1392,12 +1392,11 @@ void APIConnection::update_command(const UpdateCommandRequest &msg) { } #endif -bool APIConnection::try_send_log_message(int level, const char *tag, const char *line) { +bool APIConnection::try_send_log_message(int level, const char *tag, const char *line, size_t message_len) { if (this->flags_.log_subscription < level) return false; // Pre-calculate message size to avoid reallocations - const size_t line_length = strlen(line); uint32_t msg_size = 0; // Add size for level field (field ID 1, varint type) @@ -1406,14 +1405,14 @@ bool APIConnection::try_send_log_message(int level, const char *tag, const char // Add size for string field (field ID 3, string type) // 1 byte for field tag + size of length varint + string length - msg_size += 1 + api::ProtoSize::varint(static_cast(line_length)) + line_length; + msg_size += 1 + api::ProtoSize::varint(static_cast(message_len)) + message_len; // Create a pre-sized buffer auto buffer = this->create_buffer(msg_size); // Encode the message (SubscribeLogsResponse) buffer.encode_uint32(1, static_cast(level)); // LogLevel level = 1 - buffer.encode_string(3, line, line_length); // string message = 3 + buffer.encode_string(3, line, message_len); // string message = 3 // SubscribeLogsResponse - 29 return this->send_buffer(buffer, SubscribeLogsResponse::MESSAGE_TYPE); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index aa323d339d..b70b037999 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -107,7 +107,7 @@ class APIConnection : public APIServerConnection { bool send_media_player_state(media_player::MediaPlayer *media_player); void media_player_command(const MediaPlayerCommandRequest &msg) override; #endif - bool try_send_log_message(int level, const char *tag, const char *line); + bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len); void send_homeassistant_service_call(const HomeassistantServiceResponse &call) { if (!this->flags_.service_call_subscription) return; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 70f2ff714d..0915746381 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -104,18 +104,19 @@ void APIServer::setup() { #ifdef USE_LOGGER if (logger::global_logger != nullptr) { - logger::global_logger->add_on_log_callback([this](int level, const char *tag, const char *message) { - if (this->shutting_down_) { - // Don't try to send logs during shutdown - // as it could result in a recursion and - // we would be filling a buffer we are trying to clear - return; - } - for (auto &c : this->clients_) { - if (!c->flags_.remove) - c->try_send_log_message(level, tag, message); - } - }); + logger::global_logger->add_on_log_callback( + [this](int level, const char *tag, const char *message, size_t message_len) { + if (this->shutting_down_) { + // Don't try to send logs during shutdown + // as it could result in a recursion and + // we would be filling a buffer we are trying to clear + return; + } + for (auto &c : this->clients_) { + if (!c->flags_.remove) + c->try_send_log_message(level, tag, message, message_len); + } + }); } #endif diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index a2c2aa0320..db807f7e53 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -90,6 +90,25 @@ void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const ch #ifdef USE_STORE_LOG_STR_IN_FLASH // Implementation for ESP8266 with flash string support. // Note: USE_STORE_LOG_STR_IN_FLASH is only defined for ESP8266. +// +// This function handles format strings stored in flash memory (PROGMEM) to save RAM. +// The buffer is used in a special way to avoid allocating extra memory: +// +// Memory layout during execution: +// Step 1: Copy format string from flash to buffer +// tx_buffer_: [format_string][null][.....................] +// tx_buffer_at_: ------------------^ +// msg_start: saved here -----------^ +// +// Step 2: format_log_to_buffer_with_terminator_ reads format string from beginning +// and writes formatted output starting at msg_start position +// tx_buffer_: [format_string][null][formatted_message][null] +// tx_buffer_at_: -------------------------------------^ +// +// Step 3: Output the formatted message (starting at msg_start) +// write_msg_ and callbacks receive: this->tx_buffer_ + msg_start +// which points to: [formatted_message][null] +// void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, va_list args) { // NOLINT if (level > this->level_for(tag) || global_recursion_guard_) @@ -121,7 +140,9 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas if (this->baud_rate_ > 0) { this->write_msg_(this->tx_buffer_ + msg_start); } - this->log_callback_.call(level, tag, this->tx_buffer_ + msg_start); + size_t msg_length = + this->tx_buffer_at_ - msg_start; // Don't subtract 1 - tx_buffer_at_ is already at the null terminator position + this->log_callback_.call(level, tag, this->tx_buffer_ + msg_start, msg_length); global_recursion_guard_ = false; } @@ -185,7 +206,8 @@ 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->log_callback_.call(message->level, message->tag, this->tx_buffer_); + size_t msg_len = this->tx_buffer_at_; // We already know the length from tx_buffer_at_ + this->log_callback_.call(message->level, message->tag, this->tx_buffer_, msg_len); // 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); @@ -214,7 +236,7 @@ void Logger::set_log_level(const std::string &tag, uint8_t log_level) { this->lo UARTSelection Logger::get_uart() const { return this->uart_; } #endif -void Logger::add_on_log_callback(std::function &&callback) { +void Logger::add_on_log_callback(std::function &&callback) { this->log_callback_.add(std::move(callback)); } float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; } diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index e376d9fbf5..fb68e75a51 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -143,7 +143,7 @@ class Logger : public Component { inline uint8_t level_for(const char *tag); /// Register a callback that will be called for every log message sent - void add_on_log_callback(std::function &&callback); + void add_on_log_callback(std::function &&callback); // add a listener for log level changes void add_listener(std::function &&callback) { this->level_callback_.add(std::move(callback)); } @@ -192,7 +192,7 @@ class Logger : public Component { if (this->baud_rate_ > 0) { this->write_msg_(this->tx_buffer_); // If logging is enabled, write to console } - this->log_callback_.call(level, tag, this->tx_buffer_); + this->log_callback_.call(level, tag, this->tx_buffer_, this->tx_buffer_at_); } // Write the body of the log message to the buffer @@ -246,7 +246,7 @@ class Logger : public Component { // Large objects (internally aligned) std::map log_levels_{}; - CallbackManager log_callback_{}; + CallbackManager log_callback_{}; CallbackManager level_callback_{}; #ifdef USE_ESPHOME_TASK_LOG_BUFFER std::unique_ptr log_buffer_; // Will be initialized with init_log_buffer @@ -385,7 +385,7 @@ class LoggerMessageTrigger : public Trigger public: explicit LoggerMessageTrigger(Logger *parent, uint8_t level) { this->level_ = level; - parent->add_on_log_callback([this](uint8_t level, const char *tag, const char *message) { + parent->add_on_log_callback([this](uint8_t level, const char *tag, const char *message, size_t message_len) { if (level <= this->level_) { this->trigger(level, tag, message); } diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 20e0b4a499..ab7fd15a35 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -57,14 +57,15 @@ void MQTTClientComponent::setup() { }); #ifdef USE_LOGGER if (this->is_log_message_enabled() && logger::global_logger != nullptr) { - logger::global_logger->add_on_log_callback([this](int level, const char *tag, const char *message) { - if (level <= this->log_level_ && this->is_connected()) { - this->publish({.topic = this->log_message_.topic, - .payload = message, - .qos = this->log_message_.qos, - .retain = this->log_message_.retain}); - } - }); + logger::global_logger->add_on_log_callback( + [this](int level, const char *tag, const char *message, size_t message_len) { + if (level <= this->log_level_ && this->is_connected()) { + this->publish({.topic = this->log_message_.topic, + .payload = std::string(message, message_len), + .qos = this->log_message_.qos, + .retain = this->log_message_.retain}); + } + }); } #endif diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index 9d2cda549b..e322a6951d 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -21,10 +21,12 @@ constexpr int LOG_LEVEL_TO_SYSLOG_SEVERITY[] = { void Syslog::setup() { logger::global_logger->add_on_log_callback( - [this](int level, const char *tag, const char *message) { this->log_(level, tag, message); }); + [this](int level, const char *tag, const char *message, size_t message_len) { + this->log_(level, tag, message, message_len); + }); } -void Syslog::log_(const int level, const char *tag, const char *message) const { +void Syslog::log_(const int level, const char *tag, const char *message, size_t message_len) const { if (level > this->log_level_) return; // Syslog PRI calculation: facility * 8 + severity @@ -34,7 +36,7 @@ void Syslog::log_(const int level, const char *tag, const char *message) const { } int pri = this->facility_ * 8 + severity; auto timestamp = this->time_->now().strftime("%b %d %H:%M:%S"); - unsigned len = strlen(message); + size_t len = message_len; // remove color formatting if (this->strip_ && message[0] == 0x1B && len > 11) { message += 7; diff --git a/esphome/components/syslog/esphome_syslog.h b/esphome/components/syslog/esphome_syslog.h index 421a9bee73..e3b2f7dae5 100644 --- a/esphome/components/syslog/esphome_syslog.h +++ b/esphome/components/syslog/esphome_syslog.h @@ -17,7 +17,7 @@ class Syslog : public Component, public Parented { protected: int log_level_; - void log_(int level, const char *tag, const char *message) const; + void log_(int level, const char *tag, const char *message, size_t message_len) const; time::RealTimeClock *time_; bool strip_{true}; int facility_{16}; diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 20ff1a7c29..8ced5b7e18 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -287,7 +287,8 @@ void WebServer::setup() { if (logger::global_logger != nullptr && this->expose_log_) { logger::global_logger->add_on_log_callback( // logs are not deferred, the memory overhead would be too large - [this](int level, const char *tag, const char *message) { + [this](int level, const char *tag, const char *message, size_t message_len) { + (void) message_len; this->events_.try_send_nodefer(message, "log", millis()); }); } From 7150f2806f337caf101b402b31076bf7650bea43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Jul 2025 17:14:34 -0500 Subject: [PATCH 04/51] Run integration tests only on Python 3.13 to reduce CI resource usage (#9377) --- .github/workflows/ci.yml | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca6d1b0aac..b7546012e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,17 +214,51 @@ jobs: if: matrix.os == 'windows-latest' run: | ./venv/Scripts/activate - pytest -vv --cov-report=xml --tb=native -n auto tests + pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Run pytest if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' run: | . venv/bin/activate - pytest -vv --cov-report=xml --tb=native -n auto tests + pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} + integration-tests: + name: Run integration tests + runs-on: ubuntu-latest + needs: + - common + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.2.2 + - name: Set up Python 3.13 + id: python + uses: actions/setup-python@v5.6.0 + with: + python-version: "3.13" + - name: Restore Python virtual environment + id: cache-venv + uses: actions/cache@v4.2.3 + with: + path: venv + key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} + - name: Create Python virtual environment + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + python -m venv venv + . venv/bin/activate + python --version + pip install -r requirements.txt -r requirements_test.txt + pip install -e . + - name: Register matcher + run: echo "::add-matcher::.github/workflows/matchers/pytest.json" + - name: Run integration tests + run: | + . venv/bin/activate + pytest -vv --no-cov --tb=native -n auto tests/integration/ + clang-format: name: Check clang-format runs-on: ubuntu-24.04 @@ -494,6 +528,7 @@ jobs: - flake8 - pylint - pytest + - integration-tests - pyupgrade - clang-tidy - list-components From a72905191a2c8e97573840757fa7699d6d7df9e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Jul 2025 18:08:21 -0500 Subject: [PATCH 05/51] Fix flaky test_api_conditional_memory and improve integration test patterns (#9379) --- tests/integration/README.md | 265 ++++++++++++++++++ .../fixtures/api_conditional_memory.yaml | 43 --- .../test_api_conditional_memory.py | 179 +++--------- tests/integration/test_api_vv_logging.py | 4 +- tests/integration/test_areas_and_devices.py | 4 +- tests/integration/test_device_id_in_state.py | 54 ++-- tests/integration/test_entity_icon.py | 6 - .../test_host_mode_entity_fields.py | 4 +- .../test_host_mode_many_entities.py | 8 +- tests/integration/test_host_mode_sensor.py | 5 +- tests/integration/test_light_calls.py | 3 +- 11 files changed, 341 insertions(+), 234 deletions(-) diff --git a/tests/integration/README.md b/tests/integration/README.md index 26bd5a00ee..8fce81bb80 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -78,3 +78,268 @@ pytest -s tests/integration/test_host_mode_basic.py - Each test gets its own temporary directory and unique port - Port allocation minimizes race conditions by holding the socket until just before ESPHome starts - Output from ESPHome processes is displayed for debugging + +## Integration Test Writing Guide + +### Test Patterns and Best Practices + +#### 1. Test File Naming Convention +- Use descriptive names: `test_{category}_{feature}.py` +- Common categories: `host_mode`, `api`, `scheduler`, `light`, `areas_and_devices` +- Examples: + - `test_host_mode_basic.py` - Basic host mode functionality + - `test_api_message_batching.py` - API message batching + - `test_scheduler_stress.py` - Scheduler stress testing + +#### 2. Essential Imports +```python +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest +from aioesphomeapi import EntityState, SensorState + +from .types import APIClientConnectedFactory, RunCompiledFunction +``` + +#### 3. Common Test Patterns + +##### Basic Entity Test +```python +@pytest.mark.asyncio +async def test_my_sensor( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test sensor functionality.""" + async with run_compiled(yaml_config), api_client_connected() as client: + # Get entity list + entities, services = await client.list_entities_services() + + # Find specific entity + sensor = next((e for e in entities if e.object_id == "my_sensor"), None) + assert sensor is not None +``` + +##### State Subscription Pattern +```python +# Track state changes with futures +loop = asyncio.get_running_loop() +states: dict[int, EntityState] = {} +state_future: asyncio.Future[EntityState] = loop.create_future() + +def on_state(state: EntityState) -> None: + states[state.key] = state + # Check for specific condition using isinstance + if isinstance(state, SensorState) and state.state == expected_value: + if not state_future.done(): + state_future.set_result(state) + +client.subscribe_states(on_state) + +# Wait for state with timeout +try: + result = await asyncio.wait_for(state_future, timeout=5.0) +except asyncio.TimeoutError: + pytest.fail(f"Expected state not received. Got: {list(states.values())}") +``` + +##### Service Execution Pattern +```python +# Find and execute service +entities, services = await client.list_entities_services() +my_service = next((s for s in services if s.name == "my_service"), None) +assert my_service is not None + +# Execute with parameters +client.execute_service(my_service, {"param1": "value1", "param2": 42}) +``` + +##### Multiple Entity Tracking +```python +# For tests with many entities +loop = asyncio.get_running_loop() +entity_count = 50 +received_states: set[int] = set() +all_states_future: asyncio.Future[bool] = loop.create_future() + +def on_state(state: EntityState) -> None: + received_states.add(state.key) + if len(received_states) >= entity_count and not all_states_future.done(): + all_states_future.set_result(True) + +client.subscribe_states(on_state) +await asyncio.wait_for(all_states_future, timeout=10.0) +``` + +#### 4. YAML Fixture Guidelines + +##### Naming Convention +- Match test function name: `test_my_feature` → `fixtures/my_feature.yaml` +- Note: Remove `test_` prefix for fixture filename + +##### Basic Structure +```yaml +esphome: + name: test-name # Use kebab-case + # Optional: areas, devices, platformio_options + +host: # Always use host platform for integration tests +api: # Port injected automatically +logger: + level: DEBUG # Optional: Set log level + +# Component configurations +sensor: + - platform: template + name: "My Sensor" + id: my_sensor + lambda: return 42.0; + update_interval: 0.1s # Fast updates for testing +``` + +##### Advanced Features +```yaml +# External components for custom test code +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH # Replaced by test framework + components: [my_test_component] + +# Areas and devices +esphome: + name: test-device + areas: + - id: living_room + name: "Living Room" + - id: kitchen + name: "Kitchen" + parent_id: living_room + devices: + - id: my_device + name: "Test Device" + area_id: living_room + +# API services +api: + services: + - service: test_service + variables: + my_param: string + then: + - logger.log: + format: "Service called with: %s" + args: [my_param.c_str()] +``` + +#### 5. Testing Complex Scenarios + +##### External Components +Create C++ components in `fixtures/external_components/` for: +- Stress testing +- Custom entity behaviors +- Scheduler testing +- Memory management tests + +##### Log Line Monitoring +```python +log_lines: list[str] = [] + +def on_log_line(line: str) -> None: + log_lines.append(line) + if "expected message" in line: + # Handle specific log messages + +async with run_compiled(yaml_config, line_callback=on_log_line): + # Test implementation +``` + +Example using futures for specific log patterns: +```python +import re + +loop = asyncio.get_running_loop() +connected_future = loop.create_future() +service_future = loop.create_future() + +# Patterns to match +connected_pattern = re.compile(r"Client .* connected from") +service_pattern = re.compile(r"Service called") + +def check_output(line: str) -> None: + """Check log output for expected messages.""" + if not connected_future.done() and connected_pattern.search(line): + connected_future.set_result(True) + elif not service_future.done() and service_pattern.search(line): + service_future.set_result(True) + +async with run_compiled(yaml_config, line_callback=check_output): + async with api_client_connected() as client: + # Wait for specific log message + await asyncio.wait_for(connected_future, timeout=5.0) + + # Do test actions... + + # Wait for service log + await asyncio.wait_for(service_future, timeout=5.0) +``` + +**Note**: Tests that monitor log messages typically have fewer race conditions compared to state-based testing, making them more reliable. However, be aware that the host platform currently does not have a thread-safe logger, so logging from threads will not work correctly. + +##### Timeout Handling +```python +# Always use timeouts for async operations +try: + result = await asyncio.wait_for(some_future, timeout=5.0) +except asyncio.TimeoutError: + pytest.fail("Operation timed out - check test expectations") +``` + +#### 6. Common Assertions + +```python +# Device info +assert device_info.name == "expected-name" +assert device_info.compilation_time is not None + +# Entity properties +assert sensor.accuracy_decimals == 2 +assert sensor.state_class == 1 # measurement +assert sensor.force_update is True + +# Service availability +assert len(services) > 0 +assert any(s.name == "expected_service" for s in services) + +# State values +assert state.state == expected_value +assert state.missing_state is False +``` + +#### 7. Debugging Tips + +- Use `pytest -s` to see ESPHome output during tests +- Add descriptive failure messages to assertions +- Use `pytest.fail()` with detailed error info for timeouts +- Check `log_lines` for compilation or runtime errors +- Enable debug logging in YAML fixtures when needed + +#### 8. Performance Considerations + +- Use short update intervals (0.1s) for faster tests +- Set reasonable timeouts (5-10s for most operations) +- Batch multiple assertions when possible +- Clean up resources properly using context managers + +#### 9. Test Categories + +- **Basic Tests**: Minimal functionality verification +- **Entity Tests**: Sensor, switch, light behavior +- **API Tests**: Message batching, services, events +- **Scheduler Tests**: Timing, defer operations, stress +- **Memory Tests**: Conditional compilation, optimization +- **Integration Tests**: Areas, devices, complex interactions diff --git a/tests/integration/fixtures/api_conditional_memory.yaml b/tests/integration/fixtures/api_conditional_memory.yaml index 49412c3bfe..22e8ed79d6 100644 --- a/tests/integration/fixtures/api_conditional_memory.yaml +++ b/tests/integration/fixtures/api_conditional_memory.yaml @@ -2,14 +2,10 @@ esphome: name: api-conditional-memory-test host: api: - batch_delay: 0ms actions: - action: test_simple_service then: - logger.log: "Simple service called" - - binary_sensor.template.publish: - id: service_called_sensor - state: ON - action: test_service_with_args variables: arg_string: string @@ -20,53 +16,14 @@ api: - logger.log: format: "Service called with: %s, %d, %d, %.2f" args: [arg_string.c_str(), arg_int, arg_bool, arg_float] - - sensor.template.publish: - id: service_arg_sensor - state: !lambda 'return arg_float;' on_client_connected: - logger.log: format: "Client %s connected from %s" args: [client_info.c_str(), client_address.c_str()] - - binary_sensor.template.publish: - id: client_connected - state: ON - - text_sensor.template.publish: - id: last_client_info - state: !lambda 'return client_info;' on_client_disconnected: - logger.log: format: "Client %s disconnected from %s" args: [client_info.c_str(), client_address.c_str()] - - binary_sensor.template.publish: - id: client_connected - state: OFF - - binary_sensor.template.publish: - id: client_disconnected_event - state: ON logger: level: DEBUG - -binary_sensor: - - platform: template - name: "Client Connected" - id: client_connected - device_class: connectivity - - platform: template - name: "Client Disconnected Event" - id: client_disconnected_event - - platform: template - name: "Service Called" - id: service_called_sensor - -sensor: - - platform: template - name: "Service Argument Value" - id: service_arg_sensor - unit_of_measurement: "" - accuracy_decimals: 2 - -text_sensor: - - platform: template - name: "Last Client Info" - id: last_client_info diff --git a/tests/integration/test_api_conditional_memory.py b/tests/integration/test_api_conditional_memory.py index 8048624f70..cfa32c431d 100644 --- a/tests/integration/test_api_conditional_memory.py +++ b/tests/integration/test_api_conditional_memory.py @@ -3,15 +3,9 @@ from __future__ import annotations import asyncio +import re -from aioesphomeapi import ( - BinarySensorInfo, - EntityState, - SensorInfo, - TextSensorInfo, - UserService, - UserServiceArgType, -) +from aioesphomeapi import UserService, UserServiceArgType import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -25,50 +19,45 @@ async def test_api_conditional_memory( ) -> None: """Test API triggers and services work correctly with conditional compilation.""" loop = asyncio.get_running_loop() - # Keep ESPHome process running throughout the test - async with run_compiled(yaml_config): - # First connection + + # Track log messages + connected_future = loop.create_future() + disconnected_future = loop.create_future() + service_simple_future = loop.create_future() + service_args_future = loop.create_future() + + # Patterns to match in logs + connected_pattern = re.compile(r"Client .* connected from") + disconnected_pattern = re.compile(r"Client .* disconnected from") + service_simple_pattern = re.compile(r"Simple service called") + service_args_pattern = re.compile( + r"Service called with: test_string, 123, 1, 42\.50" + ) + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if not connected_future.done() and connected_pattern.search(line): + connected_future.set_result(True) + elif not disconnected_future.done() and disconnected_pattern.search(line): + disconnected_future.set_result(True) + elif not service_simple_future.done() and service_simple_pattern.search(line): + service_simple_future.set_result(True) + elif not service_args_future.done() and service_args_pattern.search(line): + service_args_future.set_result(True) + + # Run with log monitoring + async with run_compiled(yaml_config, line_callback=check_output): async with api_client_connected() as client: # Verify device info device_info = await client.device_info() assert device_info is not None assert device_info.name == "api-conditional-memory-test" - # List entities and services - entity_info, services = await asyncio.wait_for( - client.list_entities_services(), timeout=5.0 - ) + # Wait for connection log + await asyncio.wait_for(connected_future, timeout=5.0) - # Find our entities - client_connected: BinarySensorInfo | None = None - client_disconnected_event: BinarySensorInfo | None = None - service_called_sensor: BinarySensorInfo | None = None - service_arg_sensor: SensorInfo | None = None - last_client_info: TextSensorInfo | None = None - - for entity in entity_info: - if isinstance(entity, BinarySensorInfo): - if entity.object_id == "client_connected": - client_connected = entity - elif entity.object_id == "client_disconnected_event": - client_disconnected_event = entity - elif entity.object_id == "service_called": - service_called_sensor = entity - elif isinstance(entity, SensorInfo): - if entity.object_id == "service_argument_value": - service_arg_sensor = entity - elif isinstance(entity, TextSensorInfo): - if entity.object_id == "last_client_info": - last_client_info = entity - - # Verify all entities exist - assert client_connected is not None, "client_connected sensor not found" - assert client_disconnected_event is not None, ( - "client_disconnected_event sensor not found" - ) - assert service_called_sensor is not None, "service_called sensor not found" - assert service_arg_sensor is not None, "service_arg_sensor not found" - assert last_client_info is not None, "last_client_info sensor not found" + # List services + _, services = await client.list_entities_services() # Verify services exist assert len(services) == 2, f"Expected 2 services, found {len(services)}" @@ -98,66 +87,11 @@ async def test_api_conditional_memory( assert arg_types["arg_bool"] == UserServiceArgType.BOOL assert arg_types["arg_float"] == UserServiceArgType.FLOAT - # Track state changes - states: dict[int, EntityState] = {} - states_future: asyncio.Future[None] = loop.create_future() - - def on_state(state: EntityState) -> None: - states[state.key] = state - # Check if we have initial states for connection sensors - if ( - client_connected.key in states - and last_client_info.key in states - and not states_future.done() - ): - states_future.set_result(None) - - client.subscribe_states(on_state) - - # Wait for initial states - await asyncio.wait_for(states_future, timeout=5.0) - - # Verify on_client_connected trigger fired - connected_state = states.get(client_connected.key) - assert connected_state is not None - assert connected_state.state is True, "Client should be connected" - - # Verify client info was captured - client_info_state = states.get(last_client_info.key) - assert client_info_state is not None - assert isinstance(client_info_state.state, str) - assert len(client_info_state.state) > 0, "Client info should not be empty" - - # Test simple service - service_future: asyncio.Future[None] = loop.create_future() - - def check_service_called(state: EntityState) -> None: - if state.key == service_called_sensor.key and state.state is True: - if not service_future.done(): - service_future.set_result(None) - - # Update callback to check for service execution - client.subscribe_states(check_service_called) - # Call simple service client.execute_service(simple_service, {}) - # Wait for service to execute - await asyncio.wait_for(service_future, timeout=5.0) - - # Test service with arguments - arg_future: asyncio.Future[None] = loop.create_future() - expected_float = 42.5 - - def check_arg_sensor(state: EntityState) -> None: - if ( - state.key == service_arg_sensor.key - and abs(state.state - expected_float) < 0.01 - ): - if not arg_future.done(): - arg_future.set_result(None) - - client.subscribe_states(check_arg_sensor) + # Wait for service log + await asyncio.wait_for(service_simple_future, timeout=5.0) # Call service with arguments client.execute_service( @@ -166,43 +100,12 @@ async def test_api_conditional_memory( "arg_string": "test_string", "arg_int": 123, "arg_bool": True, - "arg_float": expected_float, + "arg_float": 42.5, }, ) - # Wait for service with args to execute - await asyncio.wait_for(arg_future, timeout=5.0) + # Wait for service with args log + await asyncio.wait_for(service_args_future, timeout=5.0) - # After disconnecting first client, reconnect and verify triggers work - async with api_client_connected() as client2: - # Subscribe to states with new client - states2: dict[int, EntityState] = {} - states_ready_future: asyncio.Future[None] = loop.create_future() - - def on_state2(state: EntityState) -> None: - states2[state.key] = state - # Check if we have received both required states - if ( - client_connected.key in states2 - and client_disconnected_event.key in states2 - and not states_ready_future.done() - ): - states_ready_future.set_result(None) - - client2.subscribe_states(on_state2) - - # Wait for both connected and disconnected event states - await asyncio.wait_for(states_ready_future, timeout=5.0) - - # Verify client is connected again (on_client_connected fired) - assert states2[client_connected.key].state is True, ( - "Client should be reconnected" - ) - - # The client_disconnected_event should be ON from when we disconnected - # (it was set ON by on_client_disconnected trigger) - disconnected_state = states2.get(client_disconnected_event.key) - assert disconnected_state is not None - assert disconnected_state.state is True, ( - "Disconnect event should be ON from previous disconnect" - ) + # Client disconnected here, wait for disconnect log + await asyncio.wait_for(disconnected_future, timeout=5.0) diff --git a/tests/integration/test_api_vv_logging.py b/tests/integration/test_api_vv_logging.py index 19aab2001c..fcbdd341ae 100644 --- a/tests/integration/test_api_vv_logging.py +++ b/tests/integration/test_api_vv_logging.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from typing import Any -from aioesphomeapi import LogLevel +from aioesphomeapi import LogLevel, SensorInfo import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -63,7 +63,7 @@ async def test_api_vv_logging( entity_info, _ = await client.list_entities_services() # Count sensors - sensor_count = sum(1 for e in entity_info if hasattr(e, "unit_of_measurement")) + sensor_count = sum(1 for e in entity_info if isinstance(e, SensorInfo)) assert sensor_count >= 10, f"Expected at least 10 sensors, got {sensor_count}" # Wait for sensor updates to flow with VV logging active diff --git a/tests/integration/test_areas_and_devices.py b/tests/integration/test_areas_and_devices.py index 4ce55a30a7..4184255724 100644 --- a/tests/integration/test_areas_and_devices.py +++ b/tests/integration/test_areas_and_devices.py @@ -76,8 +76,8 @@ async def test_areas_and_devices( # Get entity list to verify device_id mapping entities = await client.list_entities_services() - # Collect sensor entities - sensor_entities = [e for e in entities[0] if hasattr(e, "device_id")] + # Collect sensor entities (all entities have device_id) + sensor_entities = entities[0] assert len(sensor_entities) >= 4, ( f"Expected at least 4 sensor entities, got {len(sensor_entities)}" ) diff --git a/tests/integration/test_device_id_in_state.py b/tests/integration/test_device_id_in_state.py index 3c5181595f..eaa91ec92e 100644 --- a/tests/integration/test_device_id_in_state.py +++ b/tests/integration/test_device_id_in_state.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from aioesphomeapi import EntityState +from aioesphomeapi import BinarySensorState, EntityState, SensorState, TextSensorState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -40,28 +40,22 @@ async def test_device_id_in_state( entity_device_mapping: dict[int, int] = {} for entity in all_entities: - if hasattr(entity, "name") and hasattr(entity, "key"): - if entity.name == "Temperature": - entity_device_mapping[entity.key] = device_ids[ - "Temperature Monitor" - ] - elif entity.name == "Humidity": - entity_device_mapping[entity.key] = device_ids["Humidity Monitor"] - elif entity.name == "Motion Detected": - entity_device_mapping[entity.key] = device_ids["Motion Sensor"] - elif entity.name == "Temperature Monitor Power": - entity_device_mapping[entity.key] = device_ids[ - "Temperature Monitor" - ] - elif entity.name == "Temperature Status": - entity_device_mapping[entity.key] = device_ids[ - "Temperature Monitor" - ] - elif entity.name == "Motion Light": - entity_device_mapping[entity.key] = device_ids["Motion Sensor"] - elif entity.name == "No Device Sensor": - # Entity without device_id should have device_id 0 - entity_device_mapping[entity.key] = 0 + # All entities have name and key attributes + if entity.name == "Temperature": + entity_device_mapping[entity.key] = device_ids["Temperature Monitor"] + elif entity.name == "Humidity": + entity_device_mapping[entity.key] = device_ids["Humidity Monitor"] + elif entity.name == "Motion Detected": + entity_device_mapping[entity.key] = device_ids["Motion Sensor"] + elif entity.name == "Temperature Monitor Power": + entity_device_mapping[entity.key] = device_ids["Temperature Monitor"] + elif entity.name == "Temperature Status": + entity_device_mapping[entity.key] = device_ids["Temperature Monitor"] + elif entity.name == "Motion Light": + entity_device_mapping[entity.key] = device_ids["Motion Sensor"] + elif entity.name == "No Device Sensor": + # Entity without device_id should have device_id 0 + entity_device_mapping[entity.key] = 0 assert len(entity_device_mapping) >= 6, ( f"Expected at least 6 mapped entities, got {len(entity_device_mapping)}" @@ -111,7 +105,7 @@ async def test_device_id_in_state( ( s for s in states.values() - if hasattr(s, "state") + if isinstance(s, SensorState) and isinstance(s.state, float) and s.device_id != 0 ), @@ -122,11 +116,7 @@ async def test_device_id_in_state( # Find a binary sensor state binary_sensor_state = next( - ( - s - for s in states.values() - if hasattr(s, "state") and isinstance(s.state, bool) - ), + (s for s in states.values() if isinstance(s, BinarySensorState)), None, ) assert binary_sensor_state is not None, "No binary sensor state found" @@ -136,11 +126,7 @@ async def test_device_id_in_state( # Find a text sensor state text_sensor_state = next( - ( - s - for s in states.values() - if hasattr(s, "state") and isinstance(s.state, str) - ), + (s for s in states.values() if isinstance(s, TextSensorState)), None, ) assert text_sensor_state is not None, "No text sensor state found" diff --git a/tests/integration/test_entity_icon.py b/tests/integration/test_entity_icon.py index 56e266b486..aec7168165 100644 --- a/tests/integration/test_entity_icon.py +++ b/tests/integration/test_entity_icon.py @@ -51,9 +51,6 @@ async def test_entity_icon( entity = entity_map[entity_name] # Check icon field - assert hasattr(entity, "icon"), ( - f"{entity_name}: Entity should have icon attribute" - ) assert entity.icon == expected_icon, ( f"{entity_name}: icon mismatch - " f"expected '{expected_icon}', got '{entity.icon}'" @@ -67,9 +64,6 @@ async def test_entity_icon( entity = entity_map[entity_name] # Check icon field is empty - assert hasattr(entity, "icon"), ( - f"{entity_name}: Entity should have icon attribute" - ) assert entity.icon == "", ( f"{entity_name}: icon should be empty string for entities without icons, " f"got '{entity.icon}'" diff --git a/tests/integration/test_host_mode_entity_fields.py b/tests/integration/test_host_mode_entity_fields.py index cf3fa6916a..b9fa3e9746 100644 --- a/tests/integration/test_host_mode_entity_fields.py +++ b/tests/integration/test_host_mode_entity_fields.py @@ -25,8 +25,8 @@ async def test_host_mode_entity_fields( # 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 + # All entities should have a name attribute + entity_map[entity.name] = entity # Test entities that should be visible via API (non-internal) visible_test_cases = [ diff --git a/tests/integration/test_host_mode_many_entities.py b/tests/integration/test_host_mode_many_entities.py index 005728b8c6..19d1ee315f 100644 --- a/tests/integration/test_host_mode_many_entities.py +++ b/tests/integration/test_host_mode_many_entities.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from aioesphomeapi import EntityState +from aioesphomeapi import EntityState, SensorState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -30,7 +30,7 @@ async def test_host_mode_many_entities( sensor_states = [ s for s in states.values() - if hasattr(s, "state") and isinstance(s.state, float) + if isinstance(s, SensorState) and isinstance(s.state, float) ] # When we have received states from at least 50 sensors, resolve the future if len(sensor_states) >= 50 and not sensor_count_future.done(): @@ -45,7 +45,7 @@ async def test_host_mode_many_entities( sensor_states = [ s for s in states.values() - if hasattr(s, "state") and isinstance(s.state, float) + if isinstance(s, SensorState) and isinstance(s.state, float) ] pytest.fail( f"Did not receive states from at least 50 sensors within 10 seconds. " @@ -61,7 +61,7 @@ async def test_host_mode_many_entities( sensor_states = [ s for s in states.values() - if hasattr(s, "state") and isinstance(s.state, float) + if isinstance(s, SensorState) and isinstance(s.state, float) ] assert sensor_count >= 50, ( diff --git a/tests/integration/test_host_mode_sensor.py b/tests/integration/test_host_mode_sensor.py index 049f7db619..8c1e9f5d51 100644 --- a/tests/integration/test_host_mode_sensor.py +++ b/tests/integration/test_host_mode_sensor.py @@ -19,16 +19,17 @@ async def test_host_mode_with_sensor( ) -> None: """Test Host mode with a sensor component.""" # 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: # Subscribe to state changes states: dict[int, EntityState] = {} - sensor_future: asyncio.Future[EntityState] = asyncio.Future() + sensor_future: asyncio.Future[EntityState] = loop.create_future() def on_state(state: EntityState) -> None: states[state.key] = state # If this is our sensor with value 42.0, resolve the future if ( - hasattr(state, "state") + isinstance(state, aioesphomeapi.SensorState) and state.state == 42.0 and not sensor_future.done() ): diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index 8ecb77fb99..1c56bbbf9e 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -7,6 +7,7 @@ including RGB, color temperature, effects, transitions, and flash. import asyncio from typing import Any +from aioesphomeapi import LightState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -76,7 +77,7 @@ async def test_light_calls( client.light_command(key=rgbcw_light.key, white=0.6) state = await wait_for_state_change(rgbcw_light.key) # White might need more tolerance or might not be directly settable - if hasattr(state, "white"): + if isinstance(state, LightState) and state.white is not None: assert state.white == pytest.approx(0.6, abs=0.1) # Test 8: color_temperature only From 256f9f994393a37783043faaedbabac630bda2d4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 8 Jul 2025 13:30:23 +1200 Subject: [PATCH 06/51] [helpers] Improve ``format_hex_pretty`` (#9380) --- esphome/core/helpers.cpp | 65 ++++++++-------- esphome/core/helpers.h | 155 +++++++++++++++++++++++++++++++++++---- 2 files changed, 178 insertions(+), 42 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 22b74e11fa..0c11c5d486 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -258,53 +258,60 @@ std::string format_hex(const uint8_t *data, size_t length) { std::string format_hex(const std::vector &data) { return format_hex(data.data(), data.size()); } static char format_hex_pretty_char(uint8_t v) { return v >= 10 ? 'A' + (v - 10) : '0' + v; } -std::string format_hex_pretty(const uint8_t *data, size_t length) { - if (length == 0) +std::string format_hex_pretty(const uint8_t *data, size_t length, char separator, bool show_length) { + if (data == nullptr || length == 0) return ""; std::string ret; - ret.resize(3 * length - 1); + uint8_t multiple = separator ? 3 : 2; // 3 if separator is not \0, 2 otherwise + ret.resize(multiple * length - 1); for (size_t i = 0; i < length; i++) { - ret[3 * i] = format_hex_pretty_char((data[i] & 0xF0) >> 4); - ret[3 * i + 1] = format_hex_pretty_char(data[i] & 0x0F); - if (i != length - 1) - ret[3 * i + 2] = '.'; + ret[multiple * i] = format_hex_pretty_char((data[i] & 0xF0) >> 4); + ret[multiple * i + 1] = format_hex_pretty_char(data[i] & 0x0F); + if (separator && i != length - 1) + ret[multiple * i + 2] = separator; } - if (length > 4) - return ret + " (" + to_string(length) + ")"; + if (show_length && length > 4) + return ret + " (" + std::to_string(length) + ")"; return ret; } -std::string format_hex_pretty(const std::vector &data) { return format_hex_pretty(data.data(), data.size()); } +std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { + return format_hex_pretty(data.data(), data.size(), separator, show_length); +} -std::string format_hex_pretty(const uint16_t *data, size_t length) { - if (length == 0) +std::string format_hex_pretty(const uint16_t *data, size_t length, char separator, bool show_length) { + if (data == nullptr || length == 0) return ""; std::string ret; - ret.resize(5 * length - 1); + uint8_t multiple = separator ? 5 : 4; // 5 if separator is not \0, 4 otherwise + ret.resize(multiple * length - 1); for (size_t i = 0; i < length; i++) { - ret[5 * i] = format_hex_pretty_char((data[i] & 0xF000) >> 12); - ret[5 * i + 1] = format_hex_pretty_char((data[i] & 0x0F00) >> 8); - ret[5 * i + 2] = format_hex_pretty_char((data[i] & 0x00F0) >> 4); - ret[5 * i + 3] = format_hex_pretty_char(data[i] & 0x000F); - if (i != length - 1) - ret[5 * i + 2] = '.'; + ret[multiple * i] = format_hex_pretty_char((data[i] & 0xF000) >> 12); + ret[multiple * i + 1] = format_hex_pretty_char((data[i] & 0x0F00) >> 8); + ret[multiple * i + 2] = format_hex_pretty_char((data[i] & 0x00F0) >> 4); + ret[multiple * i + 3] = format_hex_pretty_char(data[i] & 0x000F); + if (separator && i != length - 1) + ret[multiple * i + 4] = separator; } - if (length > 4) - return ret + " (" + to_string(length) + ")"; + if (show_length && length > 4) + return ret + " (" + std::to_string(length) + ")"; return ret; } -std::string format_hex_pretty(const std::vector &data) { return format_hex_pretty(data.data(), data.size()); } -std::string format_hex_pretty(const std::string &data) { +std::string format_hex_pretty(const std::vector &data, char separator, bool show_length) { + return format_hex_pretty(data.data(), data.size(), separator, show_length); +} +std::string format_hex_pretty(const std::string &data, char separator, bool show_length) { if (data.empty()) return ""; std::string ret; - ret.resize(3 * data.length() - 1); + uint8_t multiple = separator ? 3 : 2; // 3 if separator is not \0, 2 otherwise + ret.resize(multiple * data.length() - 1); for (size_t i = 0; i < data.length(); i++) { - ret[3 * i] = format_hex_pretty_char((data[i] & 0xF0) >> 4); - ret[3 * i + 1] = format_hex_pretty_char(data[i] & 0x0F); - if (i != data.length() - 1) - ret[3 * i + 2] = '.'; + ret[multiple * i] = format_hex_pretty_char((data[i] & 0xF0) >> 4); + ret[multiple * i + 1] = format_hex_pretty_char(data[i] & 0x0F); + if (separator && i != data.length() - 1) + ret[multiple * i + 2] = separator; } - if (data.length() > 4) + if (show_length && data.length() > 4) return ret + " (" + std::to_string(data.length()) + ")"; return ret; } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d92cf07702..58f162ff9d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -344,20 +344,149 @@ template std::string format_hex(const std::array &dat return format_hex(data.data(), data.size()); } -/// Format the byte array \p data of length \p len in pretty-printed, human-readable hex. -std::string format_hex_pretty(const uint8_t *data, size_t length); -/// Format the word array \p data of length \p len in pretty-printed, human-readable hex. -std::string format_hex_pretty(const uint16_t *data, size_t length); -/// Format the vector \p data in pretty-printed, human-readable hex. -std::string format_hex_pretty(const std::vector &data); -/// Format the vector \p data in pretty-printed, human-readable hex. -std::string format_hex_pretty(const std::vector &data); -/// Format the string \p data in pretty-printed, human-readable hex. -std::string format_hex_pretty(const std::string &data); -/// Format an unsigned integer in pretty-printed, human-readable hex, starting with the most significant byte. -template::value, int> = 0> std::string format_hex_pretty(T val) { +/** Format a byte array in pretty-printed, human-readable hex format. + * + * Converts binary data to a hexadecimal string representation with customizable formatting. + * Each byte is displayed as a two-digit uppercase hex value, separated by the specified separator. + * Optionally includes the total byte count in parentheses at the end. + * + * @param data Pointer to the byte array to format. + * @param length Number of bytes in the array. + * @param separator Character to use between hex bytes (default: '.'). + * @param show_length Whether to append the byte count in parentheses (default: true). + * @return Formatted hex string, e.g., "A1.B2.C3.D4.E5 (5)" or "A1:B2:C3" depending on parameters. + * + * @note Returns empty string if data is nullptr or length is 0. + * @note The length will only be appended if show_length is true AND the length is greater than 4. + * + * Example: + * @code + * uint8_t data[] = {0xA1, 0xB2, 0xC3}; + * format_hex_pretty(data, 3); // Returns "A1.B2.C3" (no length shown for <= 4 parts) + * uint8_t data2[] = {0xA1, 0xB2, 0xC3, 0xD4, 0xE5}; + * format_hex_pretty(data2, 5); // Returns "A1.B2.C3.D4.E5 (5)" + * format_hex_pretty(data2, 5, ':'); // Returns "A1:B2:C3:D4:E5 (5)" + * format_hex_pretty(data2, 5, '.', false); // Returns "A1.B2.C3.D4.E5" + * @endcode + */ +std::string format_hex_pretty(const uint8_t *data, size_t length, char separator = '.', bool show_length = true); + +/** Format a 16-bit word array in pretty-printed, human-readable hex format. + * + * Similar to the byte array version, but formats 16-bit words as 4-digit hex values. + * + * @param data Pointer to the 16-bit word array to format. + * @param length Number of 16-bit words in the array. + * @param separator Character to use between hex words (default: '.'). + * @param show_length Whether to append the word count in parentheses (default: true). + * @return Formatted hex string with 4-digit hex values per word. + * + * @note The length will only be appended if show_length is true AND the length is greater than 4. + * + * Example: + * @code + * uint16_t data[] = {0xA1B2, 0xC3D4}; + * format_hex_pretty(data, 2); // Returns "A1B2.C3D4" (no length shown for <= 4 parts) + * uint16_t data2[] = {0xA1B2, 0xC3D4, 0xE5F6}; + * format_hex_pretty(data2, 3); // Returns "A1B2.C3D4.E5F6 (3)" + * @endcode + */ +std::string format_hex_pretty(const uint16_t *data, size_t length, char separator = '.', bool show_length = true); + +/** Format a byte vector in pretty-printed, human-readable hex format. + * + * Convenience overload for std::vector. Formats each byte as a two-digit + * uppercase hex value with customizable separator. + * + * @param data Vector of bytes to format. + * @param separator Character to use between hex bytes (default: '.'). + * @param show_length Whether to append the byte count in parentheses (default: true). + * @return Formatted hex string representation of the vector contents. + * + * @note The length will only be appended if show_length is true AND the vector size is greater than 4. + * + * Example: + * @code + * std::vector data = {0xDE, 0xAD, 0xBE, 0xEF}; + * format_hex_pretty(data); // Returns "DE.AD.BE.EF" (no length shown for <= 4 parts) + * std::vector data2 = {0xDE, 0xAD, 0xBE, 0xEF, 0xCA}; + * format_hex_pretty(data2); // Returns "DE.AD.BE.EF.CA (5)" + * format_hex_pretty(data2, '-'); // Returns "DE-AD-BE-EF-CA (5)" + * @endcode + */ +std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); + +/** Format a 16-bit word vector in pretty-printed, human-readable hex format. + * + * Convenience overload for std::vector. Each 16-bit word is formatted + * as a 4-digit uppercase hex value in big-endian order. + * + * @param data Vector of 16-bit words to format. + * @param separator Character to use between hex words (default: '.'). + * @param show_length Whether to append the word count in parentheses (default: true). + * @return Formatted hex string representation of the vector contents. + * + * @note The length will only be appended if show_length is true AND the vector size is greater than 4. + * + * Example: + * @code + * std::vector data = {0x1234, 0x5678}; + * format_hex_pretty(data); // Returns "1234.5678" (no length shown for <= 4 parts) + * std::vector data2 = {0x1234, 0x5678, 0x9ABC}; + * format_hex_pretty(data2); // Returns "1234.5678.9ABC (3)" + * @endcode + */ +std::string format_hex_pretty(const std::vector &data, char separator = '.', bool show_length = true); + +/** Format a string's bytes in pretty-printed, human-readable hex format. + * + * Treats each character in the string as a byte and formats it in hex. + * Useful for debugging binary data stored in std::string containers. + * + * @param data String whose bytes should be formatted as hex. + * @param separator Character to use between hex bytes (default: '.'). + * @param show_length Whether to append the byte count in parentheses (default: true). + * @return Formatted hex string representation of the string's byte contents. + * + * @note The length will only be appended if show_length is true AND the string length is greater than 4. + * + * Example: + * @code + * std::string data = "ABC"; // ASCII: 0x41, 0x42, 0x43 + * format_hex_pretty(data); // Returns "41.42.43" (no length shown for <= 4 parts) + * std::string data2 = "ABCDE"; + * format_hex_pretty(data2); // Returns "41.42.43.44.45 (5)" + * @endcode + */ +std::string format_hex_pretty(const std::string &data, char separator = '.', bool show_length = true); + +/** Format an unsigned integer in pretty-printed, human-readable hex format. + * + * Converts the integer to big-endian byte order and formats each byte as hex. + * The most significant byte appears first in the output string. + * + * @tparam T Unsigned integer type (uint8_t, uint16_t, uint32_t, uint64_t, etc.). + * @param val The unsigned integer value to format. + * @param separator Character to use between hex bytes (default: '.'). + * @param show_length Whether to append the byte count in parentheses (default: true). + * @return Formatted hex string with most significant byte first. + * + * @note The length will only be appended if show_length is true AND sizeof(T) is greater than 4. + * + * Example: + * @code + * uint32_t value = 0x12345678; + * format_hex_pretty(value); // Returns "12.34.56.78" (no length shown for <= 4 parts) + * uint64_t value2 = 0x123456789ABCDEF0; + * format_hex_pretty(value2); // Returns "12.34.56.78.9A.BC.DE.F0 (8)" + * format_hex_pretty(value2, ':'); // Returns "12:34:56:78:9A:BC:DE:F0 (8)" + * format_hex_pretty(0x1234); // Returns "12.34" + * @endcode + */ +template::value, int> = 0> +std::string format_hex_pretty(T val, char separator = '.', bool show_length = true) { val = convert_big_endian(val); - return format_hex_pretty(reinterpret_cast(&val), sizeof(T)); + return format_hex_pretty(reinterpret_cast(&val), sizeof(T), separator, show_length); } /// Format the byte array \p data of length \p len in binary. From 51377b26253d865fd071df0188948a7f932654fb Mon Sep 17 00:00:00 2001 From: functionpointer Date: Tue, 8 Jul 2025 04:27:33 +0200 Subject: [PATCH 07/51] hydreon_rgxx: remove precipitation_intensity from RG9 (#9367) --- esphome/components/hydreon_rgxx/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index f81703c087..6c9f1e2877 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -111,8 +111,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MOISTURE): sensor.sensor_schema( unit_of_measurement=UNIT_INTENSITY, accuracy_decimals=0, - device_class=DEVICE_CLASS_PRECIPITATION_INTENSITY, state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:weather-rainy", ), cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, From 4648804db6843e997bd04f232f39941ec9384d94 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 8 Jul 2025 12:28:00 +1000 Subject: [PATCH 08/51] [image] Add byte order option and unit tests (#9326) --- esphome/components/const/__init__.py | 1 + esphome/components/image/__init__.py | 147 +++++++++++--- tests/component_tests/conftest.py | 54 +++++- tests/component_tests/image/config/bad.png | 0 tests/component_tests/image/config/image.png | Bin 0 -> 685 bytes .../image/config/image_test.yaml | 20 ++ tests/component_tests/image/test_init.py | 183 ++++++++++++++++++ tests/components/image/test.esp32-ard.yaml | 17 -- tests/components/image/test.esp32-c3-ard.yaml | 16 -- tests/components/image/test.esp32-c3-idf.yaml | 16 -- tests/components/image/test.esp8266-ard.yaml | 11 +- 11 files changed, 385 insertions(+), 80 deletions(-) create mode 100644 tests/component_tests/image/config/bad.png create mode 100644 tests/component_tests/image/config/image.png create mode 100644 tests/component_tests/image/config/image_test.yaml create mode 100644 tests/component_tests/image/test_init.py delete mode 100644 tests/components/image/test.esp32-ard.yaml delete mode 100644 tests/components/image/test.esp32-c3-ard.yaml delete mode 100644 tests/components/image/test.esp32-c3-idf.yaml diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 66a5fe5d81..b084622f4c 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -2,6 +2,7 @@ CODEOWNERS = ["@esphome/core"] +CONF_BYTE_ORDER = "byte_order" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ON_STATE_CHANGE = "on_state_change" CONF_REQUEST_HEADERS = "request_headers" diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 5d593ac3d4..f6d8673a08 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -10,8 +10,10 @@ from PIL import Image, UnidentifiedImageError from esphome import core, external_files import esphome.codegen as cg +from esphome.components.const import CONF_BYTE_ORDER import esphome.config_validation as cv from esphome.const import ( + CONF_DEFAULTS, CONF_DITHER, CONF_FILE, CONF_ICON, @@ -38,6 +40,7 @@ CONF_OPAQUE = "opaque" CONF_CHROMA_KEY = "chroma_key" CONF_ALPHA_CHANNEL = "alpha_channel" CONF_INVERT_ALPHA = "invert_alpha" +CONF_IMAGES = "images" TRANSPARENCY_TYPES = ( CONF_OPAQUE, @@ -188,6 +191,10 @@ class ImageRGB565(ImageEncoder): dither, invert_alpha, ) + self.big_endian = True + + def set_big_endian(self, big_endian: bool) -> None: + self.big_endian = big_endian def convert(self, image, path): return image.convert("RGBA") @@ -205,10 +212,16 @@ class ImageRGB565(ImageEncoder): g = 1 b = 0 rgb = (r << 11) | (g << 5) | b - self.data[self.index] = rgb >> 8 - self.index += 1 - self.data[self.index] = rgb & 0xFF - self.index += 1 + if self.big_endian: + self.data[self.index] = rgb >> 8 + self.index += 1 + self.data[self.index] = rgb & 0xFF + self.index += 1 + else: + self.data[self.index] = rgb & 0xFF + self.index += 1 + self.data[self.index] = rgb >> 8 + self.index += 1 if self.transparency == CONF_ALPHA_CHANNEL: if self.invert_alpha: a ^= 0xFF @@ -364,7 +377,7 @@ def validate_file_shorthand(value): value = cv.string_strict(value) parts = value.strip().split(":") if len(parts) == 2 and parts[0] in MDI_SOURCES: - match = re.match(r"[a-zA-Z0-9\-]+", parts[1]) + match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) if match is None: raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") return download_gh_svg(parts[1], parts[0]) @@ -434,20 +447,29 @@ def validate_type(image_types): def validate_settings(value): - type = value[CONF_TYPE] + """ + Validate the settings for a single image configuration. + """ + conf_type = value[CONF_TYPE] + type_class = IMAGE_TYPE[conf_type] transparency = value[CONF_TRANSPARENCY].lower() - allow_config = IMAGE_TYPE[type].allow_config - if transparency not in allow_config: + if transparency not in type_class.allow_config: raise cv.Invalid( - f"Image format '{type}' cannot have transparency: {transparency}" + f"Image format '{conf_type}' cannot have transparency: {transparency}" ) invert_alpha = value.get(CONF_INVERT_ALPHA, False) if ( invert_alpha and transparency != CONF_ALPHA_CHANNEL - and CONF_INVERT_ALPHA not in allow_config + and CONF_INVERT_ALPHA not in type_class.allow_config ): raise cv.Invalid("No alpha channel to invert") + if value.get(CONF_BYTE_ORDER) is not None and not callable( + getattr(type_class, "set_big_endian", None) + ): + raise cv.Invalid( + f"Image format '{conf_type}' does not support byte order configuration" + ) if file := value.get(CONF_FILE): file = Path(file) if is_svg_file(file): @@ -456,31 +478,82 @@ def validate_settings(value): try: Image.open(file) except UnidentifiedImageError as exc: - raise cv.Invalid(f"File can't be opened as image: {file}") from exc + raise cv.Invalid( + f"File can't be opened as image: {file.absolute()}" + ) from exc return value +IMAGE_ID_SCHEMA = { + cv.Required(CONF_ID): cv.declare_id(Image_), + cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA), + cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), +} + + +OPTIONS_SCHEMA = { + cv.Optional(CONF_RESIZE): cv.dimensions, + cv.Optional(CONF_DITHER, default="NONE"): cv.one_of( + "NONE", "FLOYDSTEINBERG", upper=True + ), + cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, + cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), + cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE), +} + +OPTIONS = [key.schema for key in OPTIONS_SCHEMA] + +# image schema with no defaults, used with `CONF_IMAGES` in the config +IMAGE_SCHEMA_NO_DEFAULTS = { + **IMAGE_ID_SCHEMA, + **{cv.Optional(key): OPTIONS_SCHEMA[key] for key in OPTIONS}, +} + BASE_SCHEMA = cv.Schema( { - cv.Required(CONF_ID): cv.declare_id(Image_), - cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA), - cv.Optional(CONF_RESIZE): cv.dimensions, - cv.Optional(CONF_DITHER, default="NONE"): cv.one_of( - "NONE", "FLOYDSTEINBERG", upper=True - ), - cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, - cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), + **IMAGE_ID_SCHEMA, + **OPTIONS_SCHEMA, } ).add_extra(validate_settings) IMAGE_SCHEMA = BASE_SCHEMA.extend( { cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), - cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), } ) +def validate_defaults(value): + """ + Validate the options for images with defaults + """ + defaults = value[CONF_DEFAULTS] + result = [] + for index, image in enumerate(value[CONF_IMAGES]): + type = image.get(CONF_TYPE, defaults.get(CONF_TYPE)) + if type is None: + raise cv.Invalid( + "Type is required either in the image config or in the defaults", + path=[CONF_IMAGES, index], + ) + type_class = IMAGE_TYPE[type] + # A default byte order should be simply ignored if the type does not support it + available_options = [*OPTIONS] + if ( + not callable(getattr(type_class, "set_big_endian", None)) + and CONF_BYTE_ORDER not in image + ): + available_options.remove(CONF_BYTE_ORDER) + config = { + **{key: image.get(key, defaults.get(key)) for key in available_options}, + **{key.schema: image[key.schema] for key in IMAGE_ID_SCHEMA}, + } + validate_settings(config) + result.append(config) + return result + + def typed_image_schema(image_type): """ Construct a schema for a specific image type, allowing transparency options @@ -523,10 +596,33 @@ def typed_image_schema(image_type): # The config schema can be a (possibly empty) single list of images, # or a dictionary of image types each with a list of images -CONFIG_SCHEMA = cv.Any( - cv.Schema({cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE}), - cv.ensure_list(IMAGE_SCHEMA), -) +# or a dictionary with keys `defaults:` and `images:` + + +def _config_schema(config): + if isinstance(config, list): + return cv.Schema([IMAGE_SCHEMA])(config) + if not isinstance(config, dict): + raise cv.Invalid( + "Badly formed image configuration, expected a list or a dictionary" + ) + if CONF_DEFAULTS in config or CONF_IMAGES in config: + return validate_defaults( + cv.Schema( + { + cv.Required(CONF_DEFAULTS): OPTIONS_SCHEMA, + cv.Required(CONF_IMAGES): cv.ensure_list(IMAGE_SCHEMA_NO_DEFAULTS), + } + )(config) + ) + if CONF_ID in config or CONF_FILE in config: + return cv.ensure_list(IMAGE_SCHEMA)([config]) + return cv.Schema( + {cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE} + )(config) + + +CONFIG_SCHEMA = _config_schema async def write_image(config, all_frames=False): @@ -585,6 +681,9 @@ async def write_image(config, all_frames=False): total_rows = height * frame_count encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha) + if byte_order := config.get(CONF_BYTE_ORDER): + # Check for valid type has already been done in validate_settings + encoder.set_big_endian(byte_order == "BIG_ENDIAN") for frame_index in range(frame_count): image.seek(frame_index) pixels = encoder.convert(image.resize((width, height)), path).getdata() diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 7aa7dfe698..b1e0eaa200 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -1,29 +1,71 @@ """Fixtures for component tests.""" +from __future__ import annotations + +from collections.abc import Callable, Generator from pathlib import Path import sys +import pytest + # Add package root to python path here = Path(__file__).parent package_root = here.parent.parent sys.path.insert(0, package_root.as_posix()) -import pytest # noqa: E402 - from esphome.__main__ import generate_cpp_contents # noqa: E402 from esphome.config import read_config # noqa: E402 from esphome.core import CORE # noqa: E402 +@pytest.fixture(autouse=True) +def config_path(request: pytest.FixtureRequest) -> Generator[None]: + """Set CORE.config_path to the component's config directory and reset it after the test.""" + original_path = CORE.config_path + config_dir = Path(request.fspath).parent / "config" + + # Check if config directory exists, if not use parent directory + if config_dir.exists(): + # Set config_path to a dummy yaml file in the config directory + # This ensures CORE.config_dir points to the config directory + CORE.config_path = str(config_dir / "dummy.yaml") + else: + CORE.config_path = str(Path(request.fspath).parent / "dummy.yaml") + + yield + CORE.config_path = original_path + + @pytest.fixture -def generate_main(): +def component_fixture_path(request: pytest.FixtureRequest) -> Callable[[str], Path]: + """Return a function to get absolute paths relative to the component's fixtures directory.""" + + def _get_path(file_name: str) -> Path: + """Get the absolute path of a file relative to the component's fixtures directory.""" + return (Path(request.fspath).parent / "fixtures" / file_name).absolute() + + return _get_path + + +@pytest.fixture +def component_config_path(request: pytest.FixtureRequest) -> Callable[[str], Path]: + """Return a function to get absolute paths relative to the component's config directory.""" + + def _get_path(file_name: str) -> Path: + """Get the absolute path of a file relative to the component's config directory.""" + return (Path(request.fspath).parent / "config" / file_name).absolute() + + return _get_path + + +@pytest.fixture +def generate_main() -> Generator[Callable[[str | Path], str]]: """Generates the C++ main.cpp file and returns it in string form.""" - def generator(path: str) -> str: - CORE.config_path = path + def generator(path: str | Path) -> str: + CORE.config_path = str(path) CORE.config = read_config({}) generate_cpp_contents(CORE.config) - print(CORE.cpp_main_section) return CORE.cpp_main_section yield generator diff --git a/tests/component_tests/image/config/bad.png b/tests/component_tests/image/config/bad.png new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/image/config/image.png b/tests/component_tests/image/config/image.png new file mode 100644 index 0000000000000000000000000000000000000000..bd2fd547833635ab465a796c9a936dcfdf4b3086 GIT binary patch literal 685 zcmV;e0#f~nP)L}=x{JFG>CwJ5D;t#m}DrRq)32-m{@Rt7*HUfm=IXNV4$eL@SH$^Fd$%z zXjoi`ptN`(B!GB`_=uRmD2yPGoKSFNfG~g{NN8A)6cAu&aBz5tz+h+?v>2eMc$h38 zh?J<9ps=W{IB0lqsBAEBj1W*HOPAS56LM;hJ$00DtXL_t(2&#jVOQxicDMK?xJC#$kXC61eE z6`~?!1rcMW6~E$=i|7CU9v?PBQe~Ad_RHLwI(2VV_bvFx6#oSfDDRv@7RX2~?N{N(R%R&zd#e0wv(eX6D$m5o=GAMo^6d3z%Q42eiWHL=s66(FHVl1vh@ zdrbP{5G*WCPwlS1d)wa8-m~yf*L6s!a$=Nc`=WEBx z8I|OWAmdKXc)1oaghWmtI0r$31bGE0NI~q2eOHhcQXqwmK5$jDb*=T%ioeZobIrF8 T1OmU^00000NkvXXu0mjf None: + """Test detection of invalid configuration.""" + with pytest.raises(cv.Invalid, match=error_match): + CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize( + "config", + [ + pytest.param( + { + "id": "image_id", + "file": "image.png", + "type": "rgb565", + "transparency": "chroma_key", + "byte_order": "little_endian", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + }, + id="single_image_all_options", + ), + pytest.param( + [ + { + "id": "image_id", + "file": "image.png", + "type": "binary", + } + ], + id="list_of_images", + ), + pytest.param( + { + "defaults": { + "type": "rgb565", + "transparency": "chroma_key", + "byte_order": "little_endian", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + }, + "images": [ + { + "id": "image_id", + "file": "image.png", + } + ], + }, + id="images_with_defaults", + ), + pytest.param( + { + "rgb565": { + "alpha_channel": [ + { + "id": "image_id", + "file": "image.png", + "transparency": "alpha_channel", + "byte_order": "little_endian", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + } + ] + }, + "binary": [ + { + "id": "image_id", + "file": "image.png", + "transparency": "opaque", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + } + ], + }, + id="type_based_organization", + ), + ], +) +def test_image_configuration_success( + config: dict[str, Any] | list[dict[str, Any]], +) -> None: + """Test successful configuration validation.""" + CONFIG_SCHEMA(config) + + +def test_image_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test image generation configuration.""" + + main_cpp = generate_main(component_config_path("image_test.yaml")) + assert "uint8_t_id[] PROGMEM = {0x24, 0x21, 0x24, 0x21" in main_cpp + assert ( + "cat_img = new image::Image(uint8_t_id, 32, 24, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);" + in main_cpp + ) diff --git a/tests/components/image/test.esp32-ard.yaml b/tests/components/image/test.esp32-ard.yaml deleted file mode 100644 index 818e720221..0000000000 --- a/tests/components/image/test.esp32-ard.yaml +++ /dev/null @@ -1,17 +0,0 @@ -spi: - - id: spi_main_lcd - clk_pin: 16 - mosi_pin: 17 - miso_pin: 32 - -display: - - platform: ili9xxx - id: main_lcd - model: ili9342 - cs_pin: 14 - dc_pin: 13 - reset_pin: 21 - invert_colors: true - -<<: !include common.yaml - diff --git a/tests/components/image/test.esp32-c3-ard.yaml b/tests/components/image/test.esp32-c3-ard.yaml deleted file mode 100644 index 4dae9cd5ec..0000000000 --- a/tests/components/image/test.esp32-c3-ard.yaml +++ /dev/null @@ -1,16 +0,0 @@ -spi: - - id: spi_main_lcd - clk_pin: 6 - mosi_pin: 7 - miso_pin: 5 - -display: - - platform: ili9xxx - id: main_lcd - model: ili9342 - cs_pin: 3 - dc_pin: 11 - reset_pin: 10 - invert_colors: true - -<<: !include common.yaml diff --git a/tests/components/image/test.esp32-c3-idf.yaml b/tests/components/image/test.esp32-c3-idf.yaml deleted file mode 100644 index 4dae9cd5ec..0000000000 --- a/tests/components/image/test.esp32-c3-idf.yaml +++ /dev/null @@ -1,16 +0,0 @@ -spi: - - id: spi_main_lcd - clk_pin: 6 - mosi_pin: 7 - miso_pin: 5 - -display: - - platform: ili9xxx - id: main_lcd - model: ili9342 - cs_pin: 3 - dc_pin: 11 - reset_pin: 10 - invert_colors: true - -<<: !include common.yaml diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index f963022ff4..626076d44e 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -13,4 +13,13 @@ display: reset_pin: 16 invert_colors: true -<<: !include common.yaml +image: + defaults: + type: rgb565 + transparency: opaque + byte_order: little_endian + resize: 50x50 + dither: FloydSteinberg + images: + - id: test_image + file: ../../pnglogo.png From a30f01d66851912bd1689d848e6725bddfe1ab61 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Jul 2025 23:34:39 -0500 Subject: [PATCH 09/51] Fix integration test race condition by isolating PlatformIO directories (#9383) --- tests/integration/conftest.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 8f5f77ca52..aead6a73af 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -165,6 +165,19 @@ async def compile_esphome( """Compile an ESPHome configuration and return the binary path.""" async def _compile(config_path: Path) -> Path: + # Create a unique PlatformIO directory for this test to avoid race conditions + platformio_dir = integration_test_dir / ".platformio" + platformio_dir.mkdir(parents=True, exist_ok=True) + + # Create cache directory as well + platformio_cache_dir = platformio_dir / ".cache" + platformio_cache_dir.mkdir(parents=True, exist_ok=True) + + # Set up environment with isolated PlatformIO directories + env = os.environ.copy() + env["PLATFORMIO_CORE_DIR"] = str(platformio_dir) + env["PLATFORMIO_CACHE_DIR"] = str(platformio_cache_dir) + # Retry compilation up to 3 times if we get a segfault max_retries = 3 for attempt in range(max_retries): @@ -179,6 +192,7 @@ async def compile_esphome( stdin=asyncio.subprocess.DEVNULL, # Start in a new process group to isolate signal handling start_new_session=True, + env=env, ) await proc.wait() From 3f8b691c3295cb598486c6cd2027cad524201893 Mon Sep 17 00:00:00 2001 From: tmpeh <41875356+tmpeh@users.noreply.github.com> Date: Tue, 8 Jul 2025 06:39:07 +0200 Subject: [PATCH 10/51] Fix format string error in waveshare_epaper.cpp (#9322) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/waveshare_epaper/waveshare_epaper.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.cpp b/esphome/components/waveshare_epaper/waveshare_epaper.cpp index 575234e780..75c6b84b79 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.cpp +++ b/esphome/components/waveshare_epaper/waveshare_epaper.cpp @@ -3132,7 +3132,7 @@ void HOT GDEY0583T81::display() { } else { // Partial out (PTOUT), makes the display exit partial mode this->command(0x92); - ESP_LOGD(TAG, "Partial update done, next full update after %d cycles", + ESP_LOGD(TAG, "Partial update done, next full update after %" PRIu32 " cycles", this->full_update_every_ - this->at_update_ - 1); } From b3d7f001af24a28e2820b173a26114d4c958e600 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Jul 2025 06:54:47 -0500 Subject: [PATCH 11/51] Fix race condition in scheduler string lifetime integration test (#9382) --- .../string_lifetime_component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp index d377c1fe57..ea386881b2 100644 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp @@ -26,7 +26,6 @@ void SchedulerStringLifetimeComponent::run_string_lifetime_test() { // Schedule final check this->set_timeout("final_check", 200, [this]() { - ESP_LOGI(TAG, "String lifetime tests complete"); ESP_LOGI(TAG, "Tests passed: %d", this->tests_passed_); ESP_LOGI(TAG, "Tests failed: %d", this->tests_failed_); @@ -35,6 +34,7 @@ void SchedulerStringLifetimeComponent::run_string_lifetime_test() { } else { ESP_LOGE(TAG, "FAILURE: %d string lifetime tests failed!", this->tests_failed_); } + ESP_LOGI(TAG, "String lifetime tests complete"); }); } From 691cc5f7dc2d8f3dcd98b541551262c28d9f7994 Mon Sep 17 00:00:00 2001 From: Simonas Kazlauskas Date: Wed, 9 Jul 2025 00:13:58 +0300 Subject: [PATCH 12/51] lps22: add a component (#7540) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: Keith Burzinski --- CODEOWNERS | 1 + esphome/components/lps22/__init__.py | 0 esphome/components/lps22/lps22.cpp | 75 +++++++++++++++++++ esphome/components/lps22/lps22.h | 27 +++++++ esphome/components/lps22/sensor.py | 58 ++++++++++++++ tests/components/lps22/common.yaml | 8 ++ tests/components/lps22/test.esp32-ard.yaml | 6 ++ tests/components/lps22/test.esp32-c3-ard.yaml | 6 ++ tests/components/lps22/test.esp32-c3-idf.yaml | 6 ++ tests/components/lps22/test.esp32-idf.yaml | 6 ++ tests/components/lps22/test.esp8266-ard.yaml | 6 ++ tests/components/lps22/test.rp2040-ard.yaml | 6 ++ 12 files changed, 205 insertions(+) create mode 100644 esphome/components/lps22/__init__.py create mode 100644 esphome/components/lps22/lps22.cpp create mode 100644 esphome/components/lps22/lps22.h create mode 100644 esphome/components/lps22/sensor.py create mode 100644 tests/components/lps22/common.yaml create mode 100644 tests/components/lps22/test.esp32-ard.yaml create mode 100644 tests/components/lps22/test.esp32-c3-ard.yaml create mode 100644 tests/components/lps22/test.esp32-c3-idf.yaml create mode 100644 tests/components/lps22/test.esp32-idf.yaml create mode 100644 tests/components/lps22/test.esp8266-ard.yaml create mode 100644 tests/components/lps22/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index ca3849eb0d..faece3fdd7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -254,6 +254,7 @@ esphome/components/ln882x/* @lamauny esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/logger/select/* @clydebarrow +esphome/components/lps22/* @nagisa esphome/components/ltr390/* @latonita @sjtrny esphome/components/ltr501/* @latonita esphome/components/ltr_als_ps/* @latonita diff --git a/esphome/components/lps22/__init__.py b/esphome/components/lps22/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/lps22/lps22.cpp b/esphome/components/lps22/lps22.cpp new file mode 100644 index 0000000000..526286ba72 --- /dev/null +++ b/esphome/components/lps22/lps22.cpp @@ -0,0 +1,75 @@ +#include "lps22.h" + +namespace esphome { +namespace lps22 { + +static constexpr const char *const TAG = "lps22"; + +static constexpr uint8_t WHO_AM_I = 0x0F; +static constexpr uint8_t LPS22HB_ID = 0xB1; +static constexpr uint8_t LPS22HH_ID = 0xB3; +static constexpr uint8_t CTRL_REG2 = 0x11; +static constexpr uint8_t CTRL_REG2_ONE_SHOT_MASK = 0b1; +static constexpr uint8_t STATUS = 0x27; +static constexpr uint8_t STATUS_T_DA_MASK = 0b10; +static constexpr uint8_t STATUS_P_DA_MASK = 0b01; +static constexpr uint8_t TEMP_L = 0x2b; +static constexpr uint8_t PRES_OUT_XL = 0x28; +static constexpr uint8_t REF_P_XL = 0x28; +static constexpr uint8_t READ_ATTEMPTS = 10; +static constexpr uint8_t READ_INTERVAL = 5; +static constexpr float PRESSURE_SCALE = 1.0f / 4096.0f; +static constexpr float TEMPERATURE_SCALE = 0.01f; + +void LPS22Component::setup() { + uint8_t value = 0x00; + this->read_register(WHO_AM_I, &value, 1); + if (value != LPS22HB_ID && value != LPS22HH_ID) { + ESP_LOGW(TAG, "device IDs as %02x, which isn't a known LPS22HB or LPS22HH ID", value); + this->mark_failed(); + } +} + +void LPS22Component::dump_config() { + ESP_LOGCONFIG(TAG, "LPS22:"); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +} + +void LPS22Component::update() { + uint8_t value = 0x00; + this->read_register(CTRL_REG2, &value, 1); + value |= CTRL_REG2_ONE_SHOT_MASK; + this->write_register(CTRL_REG2, &value, 1); + this->set_retry(READ_INTERVAL, READ_ATTEMPTS, [this](uint8_t _) { return this->try_read_(); }); +} + +RetryResult LPS22Component::try_read_() { + uint8_t value = 0x00; + this->read_register(STATUS, &value, 1); + const uint8_t expected_status_mask = STATUS_T_DA_MASK | STATUS_P_DA_MASK; + if ((value & expected_status_mask) != expected_status_mask) { + ESP_LOGD(TAG, "STATUS not ready: %x", value); + return RetryResult::RETRY; + } + + if (this->temperature_sensor_ != nullptr) { + uint8_t t_buf[2]{0}; + this->read_register(TEMP_L, t_buf, 2); + int16_t encoded = static_cast(encode_uint16(t_buf[1], t_buf[0])); + float temp = TEMPERATURE_SCALE * static_cast(encoded); + this->temperature_sensor_->publish_state(temp); + } + if (this->pressure_sensor_ != nullptr) { + uint8_t p_buf[3]{0}; + this->read_register(PRES_OUT_XL, p_buf, 3); + uint32_t p_lsb = encode_uint24(p_buf[2], p_buf[1], p_buf[0]); + this->pressure_sensor_->publish_state(PRESSURE_SCALE * static_cast(p_lsb)); + } + return RetryResult::DONE; +} + +} // namespace lps22 +} // namespace esphome diff --git a/esphome/components/lps22/lps22.h b/esphome/components/lps22/lps22.h new file mode 100644 index 0000000000..549ea524ea --- /dev/null +++ b/esphome/components/lps22/lps22.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome { +namespace lps22 { + +class LPS22Component : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { + public: + void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } + void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } + + void setup() override; + void update() override; + void dump_config() override; + + protected: + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *pressure_sensor_{nullptr}; + + RetryResult try_read_(); +}; + +} // namespace lps22 +} // namespace esphome diff --git a/esphome/components/lps22/sensor.py b/esphome/components/lps22/sensor.py new file mode 100644 index 0000000000..87a2106308 --- /dev/null +++ b/esphome/components/lps22/sensor.py @@ -0,0 +1,58 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import i2c, sensor +from esphome.const import ( + CONF_ID, + CONF_TEMPERATURE, + CONF_PRESSURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_HECTOPASCAL, + ICON_THERMOMETER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_PRESSURE, +) + +CODEOWNERS = ["@nagisa"] +DEPENDENCIES = ["i2c"] + +lps22 = cg.esphome_ns.namespace("lps22") + +LPS22Component = lps22.class_("LPS22Component", cg.PollingComponent, i2c.I2CDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LPS22Component), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PRESSURE): sensor.sensor_schema( + unit_of_measurement=UNIT_HECTOPASCAL, + accuracy_decimals=2, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x5D)) # can also be 0x5C +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(var.set_temperature_sensor(sens)) + + if pressure_config := config.get(CONF_PRESSURE): + sens = await sensor.new_sensor(pressure_config) + cg.add(var.set_pressure_sensor(sens)) diff --git a/tests/components/lps22/common.yaml b/tests/components/lps22/common.yaml new file mode 100644 index 0000000000..e6de4752ba --- /dev/null +++ b/tests/components/lps22/common.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: lps22 + address: 0x5d + update_interval: 10s + temperature: + name: "LPS22 Temperature" + pressure: + name: "LPS22 Pressure" diff --git a/tests/components/lps22/test.esp32-ard.yaml b/tests/components/lps22/test.esp32-ard.yaml new file mode 100644 index 0000000000..0da6a9577e --- /dev/null +++ b/tests/components/lps22/test.esp32-ard.yaml @@ -0,0 +1,6 @@ +i2c: + - id: i2c_lps22 + scl: 16 + sda: 17 + +<<: !include common.yaml diff --git a/tests/components/lps22/test.esp32-c3-ard.yaml b/tests/components/lps22/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..6091393d31 --- /dev/null +++ b/tests/components/lps22/test.esp32-c3-ard.yaml @@ -0,0 +1,6 @@ +i2c: + - id: i2c_lps22 + scl: 5 + sda: 4 + +<<: !include common.yaml diff --git a/tests/components/lps22/test.esp32-c3-idf.yaml b/tests/components/lps22/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..6091393d31 --- /dev/null +++ b/tests/components/lps22/test.esp32-c3-idf.yaml @@ -0,0 +1,6 @@ +i2c: + - id: i2c_lps22 + scl: 5 + sda: 4 + +<<: !include common.yaml diff --git a/tests/components/lps22/test.esp32-idf.yaml b/tests/components/lps22/test.esp32-idf.yaml new file mode 100644 index 0000000000..0da6a9577e --- /dev/null +++ b/tests/components/lps22/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +i2c: + - id: i2c_lps22 + scl: 16 + sda: 17 + +<<: !include common.yaml diff --git a/tests/components/lps22/test.esp8266-ard.yaml b/tests/components/lps22/test.esp8266-ard.yaml new file mode 100644 index 0000000000..6091393d31 --- /dev/null +++ b/tests/components/lps22/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +i2c: + - id: i2c_lps22 + scl: 5 + sda: 4 + +<<: !include common.yaml diff --git a/tests/components/lps22/test.rp2040-ard.yaml b/tests/components/lps22/test.rp2040-ard.yaml new file mode 100644 index 0000000000..6091393d31 --- /dev/null +++ b/tests/components/lps22/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +i2c: + - id: i2c_lps22 + scl: 5 + sda: 4 + +<<: !include common.yaml From 78eb236a4ad8b5ce70a7f6eacfa54e1d8909000b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 9 Jul 2025 09:47:42 +1200 Subject: [PATCH 13/51] [nfc] Update code to use ``format_hex_pretty`` (#9384) --- esphome/components/nfc/nfc.cpp | 25 +++---------------------- esphome/components/nfc/nfc.h | 6 +++--- 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/esphome/components/nfc/nfc.cpp b/esphome/components/nfc/nfc.cpp index cf5a7f5ef1..d3a2481693 100644 --- a/esphome/components/nfc/nfc.cpp +++ b/esphome/components/nfc/nfc.cpp @@ -1,5 +1,6 @@ #include "nfc.h" #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -7,29 +8,9 @@ namespace nfc { static const char *const TAG = "nfc"; -std::string format_uid(std::vector &uid) { - char buf[(uid.size() * 2) + uid.size() - 1]; - int offset = 0; - for (size_t i = 0; i < uid.size(); i++) { - const char *format = "%02X"; - if (i + 1 < uid.size()) - format = "%02X-"; - offset += sprintf(buf + offset, format, uid[i]); - } - return std::string(buf); -} +std::string format_uid(const std::vector &uid) { return format_hex_pretty(uid, '-', false); } -std::string format_bytes(std::vector &bytes) { - char buf[(bytes.size() * 2) + bytes.size() - 1]; - int offset = 0; - for (size_t i = 0; i < bytes.size(); i++) { - const char *format = "%02X"; - if (i + 1 < bytes.size()) - format = "%02X "; - offset += sprintf(buf + offset, format, bytes[i]); - } - return std::string(buf); -} +std::string format_bytes(const std::vector &bytes) { return format_hex_pretty(bytes, ' ', false); } uint8_t guess_tag_type(uint8_t uid_length) { if (uid_length == 4) { diff --git a/esphome/components/nfc/nfc.h b/esphome/components/nfc/nfc.h index 2e5c5cd9c5..9879cfdb03 100644 --- a/esphome/components/nfc/nfc.h +++ b/esphome/components/nfc/nfc.h @@ -2,8 +2,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "ndef_record.h" #include "ndef_message.h" +#include "ndef_record.h" #include "nfc_tag.h" #include @@ -53,8 +53,8 @@ static const uint8_t DEFAULT_KEY[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; static const uint8_t NDEF_KEY[6] = {0xD3, 0xF7, 0xD3, 0xF7, 0xD3, 0xF7}; static const uint8_t MAD_KEY[6] = {0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5}; -std::string format_uid(std::vector &uid); -std::string format_bytes(std::vector &bytes); +std::string format_uid(const std::vector &uid); +std::string format_bytes(const std::vector &bytes); uint8_t guess_tag_type(uint8_t uid_length); uint8_t get_mifare_classic_ndef_start_index(std::vector &data); From 05c53644900093d5bf9110d1d930444a8f3f694b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:13:21 +1200 Subject: [PATCH 14/51] [helpers] Fix ``format_hex_pretty`` resize without separator (#9389) Co-authored-by: RubenKelevra --- esphome/core/helpers.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 0c11c5d486..b46077af02 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -263,7 +263,7 @@ std::string format_hex_pretty(const uint8_t *data, size_t length, char separator return ""; std::string ret; uint8_t multiple = separator ? 3 : 2; // 3 if separator is not \0, 2 otherwise - ret.resize(multiple * length - 1); + ret.resize(multiple * length - (separator ? 1 : 0)); for (size_t i = 0; i < length; i++) { ret[multiple * i] = format_hex_pretty_char((data[i] & 0xF0) >> 4); ret[multiple * i + 1] = format_hex_pretty_char(data[i] & 0x0F); @@ -283,7 +283,7 @@ std::string format_hex_pretty(const uint16_t *data, size_t length, char separato return ""; std::string ret; uint8_t multiple = separator ? 5 : 4; // 5 if separator is not \0, 4 otherwise - ret.resize(multiple * length - 1); + ret.resize(multiple * length - (separator ? 1 : 0)); for (size_t i = 0; i < length; i++) { ret[multiple * i] = format_hex_pretty_char((data[i] & 0xF000) >> 12); ret[multiple * i + 1] = format_hex_pretty_char((data[i] & 0x0F00) >> 8); @@ -304,7 +304,7 @@ std::string format_hex_pretty(const std::string &data, char separator, bool show return ""; std::string ret; uint8_t multiple = separator ? 3 : 2; // 3 if separator is not \0, 2 otherwise - ret.resize(multiple * data.length() - 1); + ret.resize(multiple * data.length() - (separator ? 1 : 0)); for (size_t i = 0; i < data.length(); i++) { ret[multiple * i] = format_hex_pretty_char((data[i] & 0xF0) >> 4); ret[multiple * i + 1] = format_hex_pretty_char(data[i] & 0x0F); From 4158a5c2a3af1208b09dcd092096a807eab8c0e3 Mon Sep 17 00:00:00 2001 From: Petr Kejval Date: Wed, 9 Jul 2025 00:50:45 +0200 Subject: [PATCH 15/51] Add support for GL-R01 I2C - Time of Flight sensor (#8329) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/gl_r01_i2c/__init__.py | 0 esphome/components/gl_r01_i2c/gl_r01_i2c.cpp | 68 +++++++++++++++++++ esphome/components/gl_r01_i2c/gl_r01_i2c.h | 22 ++++++ esphome/components/gl_r01_i2c/sensor.py | 36 ++++++++++ tests/components/gl_r01_i2c/common.yaml | 12 ++++ .../components/gl_r01_i2c/test.esp32-ard.yaml | 5 ++ .../gl_r01_i2c/test.esp32-c3-ard.yaml | 5 ++ .../gl_r01_i2c/test.esp32-c3-idf.yaml | 5 ++ .../components/gl_r01_i2c/test.esp32-idf.yaml | 5 ++ .../gl_r01_i2c/test.esp8266-ard.yaml | 5 ++ .../gl_r01_i2c/test.rp2040-ard.yaml | 5 ++ 12 files changed, 169 insertions(+) create mode 100644 esphome/components/gl_r01_i2c/__init__.py create mode 100644 esphome/components/gl_r01_i2c/gl_r01_i2c.cpp create mode 100644 esphome/components/gl_r01_i2c/gl_r01_i2c.h create mode 100644 esphome/components/gl_r01_i2c/sensor.py create mode 100644 tests/components/gl_r01_i2c/common.yaml create mode 100644 tests/components/gl_r01_i2c/test.esp32-ard.yaml create mode 100644 tests/components/gl_r01_i2c/test.esp32-c3-ard.yaml create mode 100644 tests/components/gl_r01_i2c/test.esp32-c3-idf.yaml create mode 100644 tests/components/gl_r01_i2c/test.esp32-idf.yaml create mode 100644 tests/components/gl_r01_i2c/test.esp8266-ard.yaml create mode 100644 tests/components/gl_r01_i2c/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index faece3fdd7..9b4681fcf2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -170,6 +170,7 @@ esphome/components/ft5x06/* @clydebarrow esphome/components/ft63x6/* @gpambrozio esphome/components/gcja5/* @gcormier esphome/components/gdk101/* @Szewcson +esphome/components/gl_r01_i2c/* @pkejval esphome/components/globals/* @esphome/core esphome/components/gp2y1010au0f/* @zry98 esphome/components/gp8403/* @jesserockz diff --git a/esphome/components/gl_r01_i2c/__init__.py b/esphome/components/gl_r01_i2c/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp b/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp new file mode 100644 index 0000000000..5a24c63525 --- /dev/null +++ b/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp @@ -0,0 +1,68 @@ +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "gl_r01_i2c.h" + +namespace esphome { +namespace gl_r01_i2c { + +static const char *const TAG = "gl_r01_i2c"; + +// Register definitions from datasheet +static const uint8_t REG_VERSION = 0x00; +static const uint8_t REG_DISTANCE = 0x02; +static const uint8_t REG_TRIGGER = 0x10; +static const uint8_t CMD_TRIGGER = 0xB0; +static const uint8_t RESTART_CMD1 = 0x5A; +static const uint8_t RESTART_CMD2 = 0xA5; +static const uint8_t READ_DELAY = 40; // minimum milliseconds from datasheet to safely read measurement result + +void GLR01I2CComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up GL-R01 I2C..."); + // Verify sensor presence + if (!this->read_byte_16(REG_VERSION, &this->version_)) { + ESP_LOGE(TAG, "Failed to communicate with GL-R01 I2C sensor!"); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Found GL-R01 I2C with version 0x%04X", this->version_); +} + +void GLR01I2CComponent::dump_config() { + ESP_LOGCONFIG(TAG, "GL-R01 I2C:"); + ESP_LOGCONFIG(TAG, " Firmware Version: 0x%04X", this->version_); + LOG_I2C_DEVICE(this); + LOG_SENSOR(" ", "Distance", this); +} + +void GLR01I2CComponent::update() { + // Trigger a new measurement + if (!this->write_byte(REG_TRIGGER, CMD_TRIGGER)) { + ESP_LOGE(TAG, "Failed to trigger measurement!"); + this->status_set_warning(); + return; + } + + // Schedule reading the result after the read delay + this->set_timeout(READ_DELAY, [this]() { this->read_distance_(); }); +} + +void GLR01I2CComponent::read_distance_() { + uint16_t distance = 0; + if (!this->read_byte_16(REG_DISTANCE, &distance)) { + ESP_LOGE(TAG, "Failed to read distance value!"); + this->status_set_warning(); + return; + } + + if (distance == 0xFFFF) { + ESP_LOGW(TAG, "Invalid measurement received!"); + this->status_set_warning(); + } else { + ESP_LOGV(TAG, "Distance: %umm", distance); + this->publish_state(distance); + this->status_clear_warning(); + } +} + +} // namespace gl_r01_i2c +} // namespace esphome diff --git a/esphome/components/gl_r01_i2c/gl_r01_i2c.h b/esphome/components/gl_r01_i2c/gl_r01_i2c.h new file mode 100644 index 0000000000..9a7aa023fd --- /dev/null +++ b/esphome/components/gl_r01_i2c/gl_r01_i2c.h @@ -0,0 +1,22 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome { +namespace gl_r01_i2c { + +class GLR01I2CComponent : public sensor::Sensor, public i2c::I2CDevice, public PollingComponent { + public: + void setup() override; + void dump_config() override; + void update() override; + + protected: + void read_distance_(); + uint16_t version_{0}; +}; + +} // namespace gl_r01_i2c +} // namespace esphome diff --git a/esphome/components/gl_r01_i2c/sensor.py b/esphome/components/gl_r01_i2c/sensor.py new file mode 100644 index 0000000000..9f6f75faf7 --- /dev/null +++ b/esphome/components/gl_r01_i2c/sensor.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import i2c, sensor +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_DISTANCE, + STATE_CLASS_MEASUREMENT, + UNIT_MILLIMETER, +) + +CODEOWNERS = ["@pkejval"] +DEPENDENCIES = ["i2c"] + +gl_r01_i2c_ns = cg.esphome_ns.namespace("gl_r01_i2c") +GLR01I2CComponent = gl_r01_i2c_ns.class_( + "GLR01I2CComponent", i2c.I2CDevice, cg.PollingComponent +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + GLR01I2CComponent, + unit_of_measurement=UNIT_MILLIMETER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x74)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + await i2c.register_i2c_device(var, config) diff --git a/tests/components/gl_r01_i2c/common.yaml b/tests/components/gl_r01_i2c/common.yaml new file mode 100644 index 0000000000..fe0705bdc6 --- /dev/null +++ b/tests/components/gl_r01_i2c/common.yaml @@ -0,0 +1,12 @@ +i2c: + - id: i2c_gl_r01_i2c + scl: ${scl_pin} + sda: ${sda_pin} + +sensor: + - platform: gl_r01_i2c + id: tof + name: "ToF sensor" + i2c_id: i2c_gl_r01_i2c + address: 0x74 + update_interval: 15s diff --git a/tests/components/gl_r01_i2c/test.esp32-ard.yaml b/tests/components/gl_r01_i2c/test.esp32-ard.yaml new file mode 100644 index 0000000000..63c3bd6afd --- /dev/null +++ b/tests/components/gl_r01_i2c/test.esp32-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO16 + sda_pin: GPIO17 + +<<: !include common.yaml diff --git a/tests/components/gl_r01_i2c/test.esp32-c3-ard.yaml b/tests/components/gl_r01_i2c/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..ee2c29ca4e --- /dev/null +++ b/tests/components/gl_r01_i2c/test.esp32-c3-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO5 + sda_pin: GPIO4 + +<<: !include common.yaml diff --git a/tests/components/gl_r01_i2c/test.esp32-c3-idf.yaml b/tests/components/gl_r01_i2c/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..ee2c29ca4e --- /dev/null +++ b/tests/components/gl_r01_i2c/test.esp32-c3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO5 + sda_pin: GPIO4 + +<<: !include common.yaml diff --git a/tests/components/gl_r01_i2c/test.esp32-idf.yaml b/tests/components/gl_r01_i2c/test.esp32-idf.yaml new file mode 100644 index 0000000000..63c3bd6afd --- /dev/null +++ b/tests/components/gl_r01_i2c/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO16 + sda_pin: GPIO17 + +<<: !include common.yaml diff --git a/tests/components/gl_r01_i2c/test.esp8266-ard.yaml b/tests/components/gl_r01_i2c/test.esp8266-ard.yaml new file mode 100644 index 0000000000..ee2c29ca4e --- /dev/null +++ b/tests/components/gl_r01_i2c/test.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO5 + sda_pin: GPIO4 + +<<: !include common.yaml diff --git a/tests/components/gl_r01_i2c/test.rp2040-ard.yaml b/tests/components/gl_r01_i2c/test.rp2040-ard.yaml new file mode 100644 index 0000000000..ee2c29ca4e --- /dev/null +++ b/tests/components/gl_r01_i2c/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO5 + sda_pin: GPIO4 + +<<: !include common.yaml From 5c8b330eaa5c6c28fdc3cc84ae65ff8f83151a88 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:51:17 +1200 Subject: [PATCH 16/51] [esp32] Improve flexibility of ``only_on_variant`` (#9390) --- esphome/components/esp32/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index b4c7a4e05b..8408f902ef 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -189,7 +189,7 @@ def get_download_types(storage_json): ] -def only_on_variant(*, supported=None, unsupported=None): +def only_on_variant(*, supported=None, unsupported=None, msg_prefix="This feature"): """Config validator for features only available on some ESP32 variants.""" if supported is not None and not isinstance(supported, list): supported = [supported] @@ -200,11 +200,11 @@ def only_on_variant(*, supported=None, unsupported=None): variant = get_esp32_variant() if supported is not None and variant not in supported: raise cv.Invalid( - f"This feature is only available on {', '.join(supported)}" + f"{msg_prefix} is only available on {', '.join(supported)}" ) if unsupported is not None and variant in unsupported: raise cv.Invalid( - f"This feature is not available on {', '.join(unsupported)}" + f"{msg_prefix} is not available on {', '.join(unsupported)}" ) return obj From 86c6e4da2a516b2c89580d279d919536c65d9f2c Mon Sep 17 00:00:00 2001 From: Craig Andrews Date: Tue, 8 Jul 2025 19:30:06 -0400 Subject: [PATCH 17/51] ESP_EXT1_WAKEUP_ANY_LOW is for s2/s3/c6/h2; ESP_EXT1_WAKEUP_ALL_LOW otherwise (#9387) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/deep_sleep/__init__.py | 39 +++++++++++++++++------ 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 55826f52bb..05ae60239d 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -1,6 +1,6 @@ from esphome import automation, pins import esphome.codegen as cg -from esphome.components import time +from esphome.components import esp32, time from esphome.components.esp32 import get_esp32_variant from esphome.components.esp32.const import ( VARIANT_ESP32, @@ -116,12 +116,20 @@ def validate_pin_number(value): return value -def validate_config(config): - if get_esp32_variant() == VARIANT_ESP32C3 and CONF_ESP32_EXT1_WAKEUP in config: - raise cv.Invalid("ESP32-C3 does not support wakeup from touch.") - if get_esp32_variant() == VARIANT_ESP32C3 and CONF_TOUCH_WAKEUP in config: - raise cv.Invalid("ESP32-C3 does not support wakeup from ext1") - return config +def _validate_ex1_wakeup_mode(value): + if value == "ALL_LOW": + esp32.only_on_variant(supported=[VARIANT_ESP32], msg_prefix="ALL_LOW")(value) + if value == "ANY_LOW": + esp32.only_on_variant( + supported=[ + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + ], + msg_prefix="ANY_LOW", + )(value) + return value deep_sleep_ns = cg.esphome_ns.namespace("deep_sleep") @@ -148,6 +156,7 @@ WAKEUP_PIN_MODES = { esp_sleep_ext1_wakeup_mode_t = cg.global_ns.enum("esp_sleep_ext1_wakeup_mode_t") Ext1Wakeup = deep_sleep_ns.struct("Ext1Wakeup") EXT1_WAKEUP_MODES = { + "ANY_LOW": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ANY_LOW, "ALL_LOW": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ALL_LOW, "ANY_HIGH": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ANY_HIGH, } @@ -187,16 +196,28 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_ESP32_EXT1_WAKEUP): cv.All( cv.only_on_esp32, + esp32.only_on_variant( + unsupported=[VARIANT_ESP32C3], msg_prefix="Wakeup from ext1" + ), cv.Schema( { cv.Required(CONF_PINS): cv.ensure_list( pins.internal_gpio_input_pin_schema, validate_pin_number ), - cv.Required(CONF_MODE): cv.enum(EXT1_WAKEUP_MODES, upper=True), + cv.Required(CONF_MODE): cv.All( + cv.enum(EXT1_WAKEUP_MODES, upper=True), + _validate_ex1_wakeup_mode, + ), } ), ), - cv.Optional(CONF_TOUCH_WAKEUP): cv.All(cv.only_on_esp32, cv.boolean), + cv.Optional(CONF_TOUCH_WAKEUP): cv.All( + cv.only_on_esp32, + esp32.only_on_variant( + unsupported=[VARIANT_ESP32C3], msg_prefix="Wakeup from touch" + ), + cv.boolean, + ), } ).extend(cv.COMPONENT_SCHEMA), cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]), From 0ccc5e340e6089bc44f9149818bc3de475d2fbd9 Mon Sep 17 00:00:00 2001 From: Merikei <73759842+Merikei@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:52:30 +0000 Subject: [PATCH 18/51] [apds9960] Add 0x9E ID (#9392) --- esphome/components/apds9960/apds9960.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/apds9960/apds9960.cpp b/esphome/components/apds9960/apds9960.cpp index 4a6ce371e5..b736e6b8b0 100644 --- a/esphome/components/apds9960/apds9960.cpp +++ b/esphome/components/apds9960/apds9960.cpp @@ -23,7 +23,7 @@ void APDS9960::setup() { return; } - if (id != 0xAB && id != 0x9C && id != 0xA8) { // APDS9960 all should have one of these IDs + if (id != 0xAB && id != 0x9C && id != 0xA8 && id != 0x9E) { // APDS9960 all should have one of these IDs this->error_code_ = WRONG_ID; this->mark_failed(); return; From 5235c80781b80e90103e8c8e3f31a2e3cee7d2dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:54:33 +0000 Subject: [PATCH 19/51] Bump aioesphomeapi from 34.1.0 to 34.2.0 (#9391) 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 a6bcebaeea..d056f22e28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.9.0 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==34.1.0 +aioesphomeapi==34.2.0 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 267574f24c4ed6104c91e4a1eedef3e4eabea0ca Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:06:52 +1200 Subject: [PATCH 20/51] Bump version to 2025.7.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 03d432b924..3d6147135b 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.7.0-dev +PROJECT_NUMBER = 2025.7.0b1 # 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 085b9b39b8..94d1379b37 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.7.0-dev" +__version__ = "2025.7.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From d24e237967e09267134ae97189ed9718f3bc27ed Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:10:51 +1200 Subject: [PATCH 21/51] Bump version to 2025.8.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 03d432b924..1f5ac5aa1b 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.7.0-dev +PROJECT_NUMBER = 2025.8.0-dev # 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 085b9b39b8..a30df6ef35 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.7.0-dev" +__version__ = "2025.8.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0ffc4463156aa254d83e6e9144a1f628a5686d2a Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Wed, 9 Jul 2025 16:15:01 +0200 Subject: [PATCH 22/51] [web_server] fix `Arudino` typo (#9404) --- esphome/components/web_server/web_server.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 0c15881d1e..ef1b03a73b 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -78,7 +78,7 @@ enum JsonDetail { DETAIL_ALL, DETAIL_STATE }; This is because only minimal changes were made to the ESPAsyncWebServer lib_dep, it was undesirable to put deferred update logic into that library. We need one deferred queue per connection so instead of one AsyncEventSource with multiple clients, we have multiple event sources with one client each. This is slightly awkward which is why it's - implemented in a more straightforward way for ESP-IDF. Arudino platform will eventually go away and this workaround + implemented in a more straightforward way for ESP-IDF. Arduino platform will eventually go away and this workaround can be forgotten. */ #ifdef USE_ARDUINO From 6616567b054b3525271db7c4a5cbcc4aac11059f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 9 Jul 2025 11:00:44 -1000 Subject: [PATCH 23/51] Speed up clang-tidy CI by 80%+ with incremental checking (#9396) --- .clang-tidy.hash | 1 + .github/workflows/ci-clang-tidy-hash.yml | 76 +++ .github/workflows/ci.yml | 52 +- .pre-commit-config.yaml | 7 + script/ci-custom.py | 2 +- script/clang-tidy | 26 +- script/clang_tidy_hash.py | 188 ++++++ script/helpers.py | 304 ++++++++- tests/script/__init__.py | 0 tests/script/test_clang_tidy_hash.py | 359 ++++++++++ tests/script/test_helpers.py | 812 +++++++++++++++++++++++ 11 files changed, 1774 insertions(+), 53 deletions(-) create mode 100644 .clang-tidy.hash create mode 100644 .github/workflows/ci-clang-tidy-hash.yml create mode 100755 script/clang_tidy_hash.py create mode 100644 tests/script/__init__.py create mode 100644 tests/script/test_clang_tidy_hash.py create mode 100644 tests/script/test_helpers.py diff --git a/.clang-tidy.hash b/.clang-tidy.hash new file mode 100644 index 0000000000..30c52f5baa --- /dev/null +++ b/.clang-tidy.hash @@ -0,0 +1 @@ +a3cdfc378d28b53b416a1d5bf0ab9077ee18867f0d39436ea8013cf5a4ead87a \ No newline at end of file diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml new file mode 100644 index 0000000000..4e89da267c --- /dev/null +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -0,0 +1,76 @@ +name: Clang-tidy Hash CI + +on: + pull_request: + paths: + - ".clang-tidy" + - "platformio.ini" + - "requirements_dev.txt" + - ".clang-tidy.hash" + - "script/clang_tidy_hash.py" + - ".github/workflows/ci-clang-tidy-hash.yml" + +permissions: + contents: read + pull-requests: write + +jobs: + verify-hash: + name: Verify clang-tidy hash + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + + - name: Set up Python + uses: actions/setup-python@v5.6.0 + with: + python-version: "3.11" + + - name: Verify hash + run: | + python script/clang_tidy_hash.py --verify + + - if: failure() + name: Show hash details + run: | + python script/clang_tidy_hash.py + echo "## Job Failed" | tee -a $GITHUB_STEP_SUMMARY + echo "You have modified clang-tidy configuration but have not updated the hash." | tee -a $GITHUB_STEP_SUMMARY + echo "Please run 'script/clang_tidy_hash.py --update' and commit the changes." | tee -a $GITHUB_STEP_SUMMARY + + - if: failure() + name: Request changes + uses: actions/github-script@v7.0.1 + with: + script: | + await github.rest.pulls.createReview({ + pull_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + event: 'REQUEST_CHANGES', + body: 'You have modified clang-tidy configuration but have not updated the hash.\nPlease run `script/clang_tidy_hash.py --update` and commit the changes.' + }) + + - if: success() + name: Dismiss review + uses: actions/github-script@v7.0.1 + with: + script: | + let reviews = await github.rest.pulls.listReviews({ + pull_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo + }); + for (let review of reviews.data) { + if (review.user.login === 'github-actions[bot]' && review.state === 'CHANGES_REQUESTED') { + await github.rest.pulls.dismissReview({ + pull_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + review_id: review.id, + message: 'Clang-tidy hash now matches configuration.' + }); + } + } + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7546012e9..ddfda042b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -297,6 +297,8 @@ jobs: - pylint - pytest - pyupgrade + env: + GH_TOKEN: ${{ github.token }} strategy: fail-fast: false max-parallel: 2 @@ -335,6 +337,7 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 + - name: Restore Python uses: ./.github/actions/restore-python with: @@ -355,6 +358,20 @@ jobs: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }} + - name: Cache platformio libdeps + if: github.ref == 'refs/heads/dev' + uses: actions/cache@v4.2.3 + with: + path: .pio/libdeps + key: pio-libdeps-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + + - name: Cache platformio libdeps + if: github.ref != 'refs/heads/dev' + uses: actions/cache/restore@v4.2.3 + with: + path: .pio/libdeps + key: pio-libdeps-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -367,10 +384,28 @@ jobs: mkdir -p .temp pio run --list-targets -e esp32-idf-tidy + - name: Check if full clang-tidy scan needed + id: check_full_scan + run: | + . venv/bin/activate + if python script/clang_tidy_hash.py --check; then + echo "full_scan=true" >> $GITHUB_OUTPUT + echo "reason=hash_changed" >> $GITHUB_OUTPUT + else + echo "full_scan=false" >> $GITHUB_OUTPUT + echo "reason=normal" >> $GITHUB_OUTPUT + fi + - name: Run clang-tidy run: | . venv/bin/activate - script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} + if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then + echo "Running FULL clang-tidy scan (hash changed)" + script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} + else + echo "Running clang-tidy on changed files only" + script/clang-tidy --all-headers --fix --changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} + fi env: # Also cache libdeps, store them in a ~/.platformio subfolder PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps @@ -385,23 +420,14 @@ jobs: needs: - common if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} outputs: components: ${{ steps.list-components.outputs.components }} count: ${{ steps.list-components.outputs.count }} steps: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - with: - # Fetch enough history so `git merge-base refs/remotes/origin/dev HEAD` works. - fetch-depth: 500 - - name: Get target branch - id: target-branch - run: | - echo "branch=${{ github.event.pull_request.base.ref }}" >> $GITHUB_OUTPUT - - name: Fetch ${{ steps.target-branch.outputs.branch }} branch - run: | - git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +refs/heads/${{ steps.target-branch.outputs.branch }}:refs/remotes/origin/${{ steps.target-branch.outputs.branch }} - git merge-base refs/remotes/origin/${{ steps.target-branch.outputs.branch }} HEAD - name: Restore Python uses: ./.github/actions/restore-python with: @@ -411,7 +437,7 @@ jobs: id: list-components run: | . venv/bin/activate - components=$(script/list-components.py --changed --branch ${{ steps.target-branch.outputs.branch }}) + components=$(script/list-components.py --changed) output_components=$(echo "$components" | jq -R -s -c 'split("\n")[:-1] | map(select(length > 0))') count=$(echo "$output_components" | jq length) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 831473c325..8336333a03 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,3 +48,10 @@ repos: entry: python3 script/run-in-env.py pylint language: system types: [python] + - id: clang-tidy-hash + name: Update clang-tidy hash + entry: python script/clang_tidy_hash.py --update-if-changed + language: python + files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt)$ + pass_filenames: false + additional_dependencies: [] diff --git a/script/ci-custom.py b/script/ci-custom.py index d0b518251f..1310a93230 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -270,7 +270,7 @@ def lint_newline(fname): return "File contains Windows newline. Please set your editor to Unix newline mode." -@lint_content_check(exclude=["*.svg"]) +@lint_content_check(exclude=["*.svg", ".clang-tidy.hash"]) def lint_end_newline(fname, content): if content and not content.endswith("\n"): return "File does not end with a newline, please add an empty line at the end of the file." diff --git a/script/clang-tidy b/script/clang-tidy index 5baaaf6b3a..b5905e0e4e 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -22,6 +22,7 @@ from helpers import ( git_ls_files, load_idedata, print_error_for_file, + print_file_list, root_path, temp_header_file, ) @@ -218,13 +219,14 @@ def main(): ) args = parser.parse_args() - idedata = load_idedata(args.environment) - options = clang_options(idedata) - files = [] for path in git_ls_files(["*.cpp"]): files.append(os.path.relpath(path, os.getcwd())) + # Print initial file count if it's large + if len(files) > 50: + print(f"Found {len(files)} total files to process") + if args.files: # Match against files specified on command-line file_name_re = re.compile("|".join(args.files)) @@ -240,10 +242,28 @@ def main(): if args.split_num: files = split_list(files, args.split_num)[args.split_at - 1] + print(f"Split {args.split_at}/{args.split_num}: checking {len(files)} files") + # Print file count before adding header file + print(f"\nTotal files to check: {len(files)}") + + # Early exit if no files to check + if not files: + print("No files to check - exiting early") + return 0 + + # Only build header file if we have actual files to check if args.all_headers and args.split_at in (None, 1): build_all_include() files.insert(0, temp_header_file) + print(f"Added all-include header file, new total: {len(files)}") + + # Print final file list before loading idedata + print_file_list(files, "Final files to process:") + + # Load idedata and options only if we have files to check + idedata = load_idedata(args.environment) + options = clang_options(idedata) tmpdir = None if args.fix: diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py new file mode 100755 index 0000000000..86f4c4e158 --- /dev/null +++ b/script/clang_tidy_hash.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Calculate and manage hash for clang-tidy configuration.""" + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path +import re +import sys + +# Add the script directory to path to import helpers +script_dir = Path(__file__).parent +sys.path.insert(0, str(script_dir)) + + +def read_file_lines(path: Path) -> list[str]: + """Read lines from a file.""" + with open(path) as f: + return f.readlines() + + +def parse_requirement_line(line: str) -> tuple[str, str] | None: + """Parse a requirement line and return (package, original_line) or None. + + Handles formats like: + - package==1.2.3 + - package==1.2.3 # comment + - package>=1.2.3,<2.0.0 + """ + original_line = line.strip() + + # Extract the part before any comment for parsing + parse_line = line + if "#" in parse_line: + parse_line = parse_line[: parse_line.index("#")] + + parse_line = parse_line.strip() + if not parse_line: + return None + + # Use regex to extract package name + # This matches package names followed by version operators + match = re.match(r"^([a-zA-Z0-9_-]+)(==|>=|<=|>|<|!=|~=)(.+)$", parse_line) + if match: + return (match.group(1), original_line) # Return package name and original line + + return None + + +def get_clang_tidy_version_from_requirements() -> str: + """Get clang-tidy version from requirements_dev.txt""" + requirements_path = Path(__file__).parent.parent / "requirements_dev.txt" + lines = read_file_lines(requirements_path) + + for line in lines: + parsed = parse_requirement_line(line) + if parsed and parsed[0] == "clang-tidy": + # Return the original line (preserves comments) + return parsed[1] + + return "clang-tidy version not found" + + +def extract_platformio_flags() -> str: + """Extract clang-tidy related flags from platformio.ini""" + flags: list[str] = [] + in_clangtidy_section = False + + platformio_path = Path(__file__).parent.parent / "platformio.ini" + lines = read_file_lines(platformio_path) + for line in lines: + line = line.strip() + if line.startswith("[flags:clangtidy]"): + in_clangtidy_section = True + continue + elif line.startswith("[") and in_clangtidy_section: + break + elif in_clangtidy_section and line and not line.startswith("#"): + flags.append(line) + + return "\n".join(sorted(flags)) + + +def read_file_bytes(path: Path) -> bytes: + """Read bytes from a file.""" + with open(path, "rb") as f: + return f.read() + + +def calculate_clang_tidy_hash() -> str: + """Calculate hash of clang-tidy configuration and version""" + hasher = hashlib.sha256() + + # Hash .clang-tidy file + clang_tidy_path = Path(__file__).parent.parent / ".clang-tidy" + content = read_file_bytes(clang_tidy_path) + hasher.update(content) + + # Hash clang-tidy version from requirements_dev.txt + version = get_clang_tidy_version_from_requirements() + hasher.update(version.encode()) + + # Hash relevant platformio.ini sections + pio_flags = extract_platformio_flags() + hasher.update(pio_flags.encode()) + + return hasher.hexdigest() + + +def read_stored_hash() -> str | None: + """Read the stored hash from file""" + hash_file = Path(__file__).parent.parent / ".clang-tidy.hash" + if hash_file.exists(): + lines = read_file_lines(hash_file) + return lines[0].strip() if lines else None + return None + + +def write_file_content(path: Path, content: str) -> None: + """Write content to a file.""" + with open(path, "w") as f: + f.write(content) + + +def write_hash(hash_value: str) -> None: + """Write hash to file""" + hash_file = Path(__file__).parent.parent / ".clang-tidy.hash" + write_file_content(hash_file, hash_value) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Manage clang-tidy configuration hash") + parser.add_argument( + "--check", + action="store_true", + help="Check if full scan needed (exit 0 if needed)", + ) + parser.add_argument("--update", action="store_true", help="Update the hash file") + parser.add_argument( + "--update-if-changed", + action="store_true", + help="Update hash only if configuration changed (for pre-commit)", + ) + parser.add_argument( + "--verify", action="store_true", help="Verify hash matches (for CI)" + ) + + args = parser.parse_args() + + current_hash = calculate_clang_tidy_hash() + stored_hash = read_stored_hash() + + if args.check: + # Exit 0 if full scan needed (hash changed or no hash file) + sys.exit(0 if current_hash != stored_hash else 1) + + elif args.update: + write_hash(current_hash) + print(f"Hash updated: {current_hash}") + + elif args.update_if_changed: + if current_hash != stored_hash: + write_hash(current_hash) + print(f"Clang-tidy hash updated: {current_hash}") + # Exit 0 so pre-commit can stage the file + sys.exit(0) + else: + print("Clang-tidy hash unchanged") + sys.exit(0) + + elif args.verify: + if current_hash != stored_hash: + print("ERROR: Clang-tidy configuration has changed but hash not updated!") + print(f"Expected: {current_hash}") + print(f"Found: {stored_hash}") + print("\nPlease run: script/clang_tidy_hash.py --update") + sys.exit(1) + print("Hash verification passed") + + else: + print(f"Current hash: {current_hash}") + print(f"Stored hash: {stored_hash}") + print(f"Match: {current_hash == stored_hash}") + + +if __name__ == "__main__": + main() diff --git a/script/helpers.py b/script/helpers.py index 1a0349e434..4528763ab3 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -1,8 +1,12 @@ +from __future__ import annotations + import json +import os import os.path from pathlib import Path import re import subprocess +import time import colorama @@ -12,13 +16,13 @@ temp_folder = os.path.join(root_path, ".temp") temp_header_file = os.path.join(temp_folder, "all-include.cpp") -def styled(color, msg, reset=True): +def styled(color: str | tuple[str, ...], msg: str, reset: bool = True) -> str: prefix = "".join(color) if isinstance(color, tuple) else color suffix = colorama.Style.RESET_ALL if reset else "" return prefix + msg + suffix -def print_error_for_file(file, body): +def print_error_for_file(file: str, body: str | None) -> None: print( styled(colorama.Fore.GREEN, "### File ") + styled((colorama.Fore.GREEN, colorama.Style.BRIGHT), file) @@ -29,17 +33,22 @@ def print_error_for_file(file, body): print() -def build_all_include(): +def build_all_include() -> None: # Build a cpp file that includes all header files in this repo. # Otherwise header-only integrations would not be tested by clang-tidy - headers = [] - for path in walk_files(basepath): - filetypes = (".h",) - ext = os.path.splitext(path)[1] - if ext in filetypes: - path = os.path.relpath(path, root_path) - include_p = path.replace(os.path.sep, "/") - headers.append(f'#include "{include_p}"') + + # Use git ls-files to find all .h files in the esphome directory + # This is much faster than walking the filesystem + cmd = ["git", "ls-files", "esphome/**/*.h"] + proc = subprocess.run(cmd, capture_output=True, text=True, check=True) + + # Process git output - git already returns paths relative to repo root + headers = [ + f'#include "{include_p}"' + for line in proc.stdout.strip().split("\n") + if (include_p := line.replace(os.path.sep, "/")) + ] + headers.sort() headers.append("") content = "\n".join(headers) @@ -48,29 +57,86 @@ def build_all_include(): p.write_text(content, encoding="utf-8") -def walk_files(path): - for root, _, files in os.walk(path): - for name in files: - yield os.path.join(root, name) - - -def get_output(*args): +def get_output(*args: str) -> str: with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as proc: output, _ = proc.communicate() return output.decode("utf-8") -def get_err(*args): +def get_err(*args: str) -> str: with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as proc: _, err = proc.communicate() return err.decode("utf-8") -def splitlines_no_ends(string): +def splitlines_no_ends(string: str) -> list[str]: return [s.strip() for s in string.splitlines()] -def changed_files(branch="dev"): +def _get_pr_number_from_github_env() -> str | None: + """Extract PR number from GitHub environment variables. + + Returns: + PR number as string, or None if not found + """ + # First try parsing GITHUB_REF (fastest) + github_ref = os.environ.get("GITHUB_REF", "") + if "/pull/" in github_ref: + return github_ref.split("/pull/")[1].split("/")[0] + + # Fallback to GitHub event file + github_event_path = os.environ.get("GITHUB_EVENT_PATH") + if github_event_path and os.path.exists(github_event_path): + with open(github_event_path) as f: + event_data = json.load(f) + pr_data = event_data.get("pull_request", {}) + if pr_number := pr_data.get("number"): + return str(pr_number) + + return None + + +def _get_changed_files_github_actions() -> list[str] | None: + """Get changed files in GitHub Actions environment. + + Returns: + List of changed files, or None if should fall back to git method + """ + event_name = os.environ.get("GITHUB_EVENT_NAME") + + # For pull requests + if event_name == "pull_request": + pr_number = _get_pr_number_from_github_env() + if pr_number: + # Use GitHub CLI to get changed files directly + cmd = ["gh", "pr", "diff", pr_number, "--name-only"] + return _get_changed_files_from_command(cmd) + + # For pushes (including squash-and-merge) + elif event_name == "push": + # For push events, we want to check what changed in this commit + try: + # Get the changed files in the last commit + return _get_changed_files_from_command( + ["git", "diff", "HEAD~1..HEAD", "--name-only"] + ) + except: # noqa: E722 + # Fall back to the original method if this fails + pass + + return None + + +def changed_files(branch: str | None = None) -> list[str]: + # In GitHub Actions, we can use the API to get changed files more efficiently + if os.environ.get("GITHUB_ACTIONS") == "true": + github_files = _get_changed_files_github_actions() + if github_files is not None: + return github_files + + # Original implementation for local development + if branch is None: + branch = "dev" check_remotes = ["upstream", "origin"] check_remotes.extend(splitlines_no_ends(get_output("git", "remote"))) for remote in check_remotes: @@ -83,25 +149,159 @@ def changed_files(branch="dev"): pass else: raise ValueError("Git not configured") - command = ["git", "diff", merge_base, "--name-only"] - changed = splitlines_no_ends(get_output(*command)) - changed = [os.path.relpath(f, os.getcwd()) for f in changed] - changed.sort() - return changed + return _get_changed_files_from_command(["git", "diff", merge_base, "--name-only"]) -def filter_changed(files): +def _get_changed_files_from_command(command: list[str]) -> list[str]: + """Run a git command to get changed files and return them as a list.""" + proc = subprocess.run(command, capture_output=True, text=True, check=False) + if proc.returncode != 0: + raise Exception(f"Command failed: {' '.join(command)}\nstderr: {proc.stderr}") + + changed_files = splitlines_no_ends(proc.stdout) + changed_files = [os.path.relpath(f, os.getcwd()) for f in changed_files if f] + changed_files.sort() + return changed_files + + +def get_changed_components() -> list[str] | None: + """Get list of changed components using list-components.py script. + + This function: + 1. First checks if any core files (esphome/core/*) changed - if so, returns None + 2. Otherwise delegates to ./script/list-components.py --changed which: + - Analyzes all changed files + - Determines which components are affected (including dependencies) + - Returns a list of component names that need to be checked + + Returns: + - None: Core files changed, need full scan + - Empty list: No components changed (only non-component files changed) + - List of strings: Names of components that need checking (e.g., ["wifi", "mqtt"]) + """ + # Check if any core files changed first changed = changed_files() - files = [f for f in files if f in changed] - print("Changed files:") - if not files: - print(" No changed files!") - for c in files: - print(f" {c}") + if any(f.startswith("esphome/core/") for f in changed): + print("Core files changed - will run full clang-tidy scan") + return None + + # Use list-components.py to get changed components + script_path = os.path.join(root_path, "script", "list-components.py") + cmd = [script_path, "--changed"] + + try: + result = subprocess.run( + cmd, capture_output=True, text=True, check=True, close_fds=False + ) + components = [c.strip() for c in result.stdout.strip().split("\n") if c.strip()] + return components + except subprocess.CalledProcessError: + # If the script fails, fall back to full scan + print("Could not determine changed components - will run full clang-tidy scan") + return None + + +def _filter_changed_ci(files: list[str]) -> list[str]: + """Filter files based on changed components in CI environment. + + This function implements intelligent filtering to reduce CI runtime by only + checking files that could be affected by the changes. It handles three scenarios: + + 1. Core files changed (returns None from get_changed_components): + - Triggered when any file in esphome/core/ is modified + - Action: Check ALL files (full scan) + - Reason: Core files are used throughout the codebase + + 2. No components changed (returns empty list from get_changed_components): + - Triggered when only non-component files changed (e.g., scripts, configs) + - Action: Check only the specific non-component files that changed + - Example: If only script/clang-tidy changed, only check that file + + 3. Specific components changed (returns list of component names): + - Component detection done by: ./script/list-components.py --changed + - That script analyzes which components are affected by the changed files + INCLUDING their dependencies + - Action: Check ALL files in each component that list-components.py identifies + - Example: If wifi.cpp changed, list-components.py might return ["wifi", "network"] + if network depends on wifi. We then check ALL files in both + esphome/components/wifi/ and esphome/components/network/ + - Reason: Component files often have interdependencies (headers, base classes) + + Args: + files: List of all files that clang-tidy would normally check + + Returns: + Filtered list of files to check + """ + components = get_changed_components() + if components is None: + # Scenario 1: Core files changed or couldn't determine components + # Action: Return all files for full scan + return files + + if not components: + # Scenario 2: No components changed - only non-component files changed + # Action: Check only the specific non-component files that changed + changed = changed_files() + files = [ + f for f in files if f in changed and not f.startswith("esphome/components/") + ] + if not files: + print("No files changed") + return files + + # Scenario 3: Specific components changed + # Action: Check ALL files in each changed component + # Convert component list to set for O(1) lookups + component_set = set(components) + print(f"Changed components: {', '.join(sorted(components))}") + + # The 'files' parameter contains ALL files in the codebase that clang-tidy would check. + # We filter this down to only files in the changed components. + # We check ALL files in each changed component (not just the changed files) + # because changes in one file can affect other files in the same component. + filtered_files = [] + for f in files: + if f.startswith("esphome/components/"): + # Check if file belongs to any of the changed components + parts = f.split("/") + if len(parts) >= 3 and parts[2] in component_set: + filtered_files.append(f) + + return filtered_files + + +def _filter_changed_local(files: list[str]) -> list[str]: + """Filter files based on git changes for local development. + + Args: + files: List of all files to filter + + Returns: + Filtered list of files to check + """ + # For local development, just check changed files directly + changed = changed_files() + return [f for f in files if f in changed] + + +def filter_changed(files: list[str]) -> list[str]: + """Filter files to only those that changed or are in changed components. + + Args: + files: List of files to filter + """ + # When running from CI, use component-based filtering + if os.environ.get("GITHUB_ACTIONS") == "true": + files = _filter_changed_ci(files) + else: + files = _filter_changed_local(files) + + print_file_list(files, "Files to check after filtering:") return files -def filter_grep(files, value): +def filter_grep(files: list[str], value: str) -> list[str]: matched = [] for file in files: with open(file, encoding="utf-8") as handle: @@ -111,7 +311,7 @@ def filter_grep(files, value): return matched -def git_ls_files(patterns=None): +def git_ls_files(patterns: list[str] | None = None) -> dict[str, int]: command = ["git", "ls-files", "-s"] if patterns is not None: command.extend(patterns) @@ -122,6 +322,9 @@ def git_ls_files(patterns=None): def load_idedata(environment): + start_time = time.time() + print(f"Loading IDE data for environment '{environment}'...") + platformio_ini = Path(root_path) / "platformio.ini" temp_idedata = Path(temp_folder) / f"idedata-{environment}.json" changed = False @@ -142,7 +345,10 @@ def load_idedata(environment): changed = True if not changed: - return json.loads(temp_idedata.read_text()) + data = json.loads(temp_idedata.read_text()) + elapsed = time.time() - start_time + print(f"IDE data loaded from cache in {elapsed:.2f} seconds") + return data # ensure temp directory exists before running pio, as it writes sdkconfig to it Path(temp_folder).mkdir(exist_ok=True) @@ -158,6 +364,9 @@ def load_idedata(environment): match = re.search(r'{\s*".*}', stdout.decode("utf-8")) data = json.loads(match.group()) temp_idedata.write_text(json.dumps(data, indent=2) + "\n") + + elapsed = time.time() - start_time + print(f"IDE data generated and cached in {elapsed:.2f} seconds") return data @@ -196,6 +405,29 @@ def get_binary(name: str, version: str) -> str: raise +def print_file_list( + files: list[str], title: str = "Files:", max_files: int = 20 +) -> None: + """Print a list of files with optional truncation for large lists. + + Args: + files: List of file paths to print + title: Title to print before the list + max_files: Maximum number of files to show before truncating (default: 20) + """ + print(title) + if not files: + print(" No files to check!") + elif len(files) <= max_files: + for f in sorted(files): + print(f" {f}") + else: + sorted_files = sorted(files) + for f in sorted_files[:10]: + print(f" {f}") + print(f" ... and {len(files) - 10} more files") + + def get_usable_cpu_count() -> int: """Return the number of CPUs that can be used for processes. diff --git a/tests/script/__init__.py b/tests/script/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py new file mode 100644 index 0000000000..dbcb477a4f --- /dev/null +++ b/tests/script/test_clang_tidy_hash.py @@ -0,0 +1,359 @@ +"""Unit tests for script/clang_tidy_hash.py module.""" + +import hashlib +from pathlib import Path +import sys +from unittest.mock import Mock, patch + +import pytest + +# Add the script directory to Python path so we can import clang_tidy_hash +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) + +import clang_tidy_hash # noqa: E402 + + +@pytest.mark.parametrize( + ("file_content", "expected"), + [ + ( + "clang-tidy==18.1.5 # via -r requirements_dev.in\n", + "clang-tidy==18.1.5 # via -r requirements_dev.in", + ), + ( + "other-package==1.0\nclang-tidy==17.0.0\nmore-packages==2.0\n", + "clang-tidy==17.0.0", + ), + ( + "# comment\nclang-tidy==16.0.0 # some comment\n", + "clang-tidy==16.0.0 # some comment", + ), + ("no-clang-tidy-here==1.0\n", "clang-tidy version not found"), + ], +) +def test_get_clang_tidy_version_from_requirements( + file_content: str, expected: str +) -> None: + """Test extracting clang-tidy version from various file formats.""" + # Mock read_file_lines to return our test content + with patch("clang_tidy_hash.read_file_lines") as mock_read: + mock_read.return_value = file_content.splitlines(keepends=True) + + result = clang_tidy_hash.get_clang_tidy_version_from_requirements() + + assert result == expected + + +@pytest.mark.parametrize( + ("platformio_content", "expected_flags"), + [ + ( + "[env:esp32]\n" + "platform = espressif32\n" + "\n" + "[flags:clangtidy]\n" + "build_flags = -Wall\n" + "extra_flags = -Wextra\n" + "\n" + "[env:esp8266]\n", + "build_flags = -Wall\nextra_flags = -Wextra", + ), + ( + "[flags:clangtidy]\n# Comment line\nbuild_flags = -O2\n\n[next_section]\n", + "build_flags = -O2", + ), + ( + "[flags:clangtidy]\nflag_c = -std=c99\nflag_b = -Wall\nflag_a = -O2\n", + "flag_a = -O2\nflag_b = -Wall\nflag_c = -std=c99", # Sorted + ), + ( + "[env:esp32]\nplatform = espressif32\n", # No clangtidy section + "", + ), + ], +) +def test_extract_platformio_flags(platformio_content: str, expected_flags: str) -> None: + """Test extracting clang-tidy flags from platformio.ini.""" + # Mock read_file_lines to return our test content + with patch("clang_tidy_hash.read_file_lines") as mock_read: + mock_read.return_value = platformio_content.splitlines(keepends=True) + + result = clang_tidy_hash.extract_platformio_flags() + + assert result == expected_flags + + +def test_calculate_clang_tidy_hash() -> None: + """Test calculating hash from all configuration sources.""" + clang_tidy_content = b"Checks: '-*,readability-*'\n" + requirements_version = "clang-tidy==18.1.5" + pio_flags = "build_flags = -Wall" + + # Expected hash calculation + expected_hasher = hashlib.sha256() + expected_hasher.update(clang_tidy_content) + expected_hasher.update(requirements_version.encode()) + expected_hasher.update(pio_flags.encode()) + expected_hash = expected_hasher.hexdigest() + + # Mock the dependencies + with ( + patch("clang_tidy_hash.read_file_bytes", return_value=clang_tidy_content), + patch( + "clang_tidy_hash.get_clang_tidy_version_from_requirements", + return_value=requirements_version, + ), + patch("clang_tidy_hash.extract_platformio_flags", return_value=pio_flags), + ): + result = clang_tidy_hash.calculate_clang_tidy_hash() + + assert result == expected_hash + + +def test_read_stored_hash_exists(tmp_path: Path) -> None: + """Test reading hash when file exists.""" + stored_hash = "abc123def456" + hash_file = tmp_path / ".clang-tidy.hash" + hash_file.write_text(f"{stored_hash}\n") + + with ( + patch("clang_tidy_hash.Path") as mock_path_class, + patch("clang_tidy_hash.read_file_lines", return_value=[f"{stored_hash}\n"]), + ): + # Mock the path calculation and exists check + mock_hash_file = Mock() + mock_hash_file.exists.return_value = True + mock_path_class.return_value.parent.parent.__truediv__.return_value = ( + mock_hash_file + ) + + result = clang_tidy_hash.read_stored_hash() + + assert result == stored_hash + + +def test_read_stored_hash_not_exists() -> None: + """Test reading hash when file doesn't exist.""" + with patch("clang_tidy_hash.Path") as mock_path_class: + # Mock the path calculation and exists check + mock_hash_file = Mock() + mock_hash_file.exists.return_value = False + mock_path_class.return_value.parent.parent.__truediv__.return_value = ( + mock_hash_file + ) + + result = clang_tidy_hash.read_stored_hash() + + assert result is None + + +def test_write_hash() -> None: + """Test writing hash to file.""" + hash_value = "abc123def456" + + with patch("clang_tidy_hash.write_file_content") as mock_write: + clang_tidy_hash.write_hash(hash_value) + + # Verify write_file_content was called with correct parameters + mock_write.assert_called_once() + args = mock_write.call_args[0] + assert str(args[0]).endswith(".clang-tidy.hash") + assert args[1] == hash_value + + +@pytest.mark.parametrize( + ("args", "current_hash", "stored_hash", "expected_exit"), + [ + (["--check"], "abc123", "abc123", 1), # Hashes match, no scan needed + (["--check"], "abc123", "def456", 0), # Hashes differ, scan needed + (["--check"], "abc123", None, 0), # No stored hash, scan needed + ], +) +def test_main_check_mode( + args: list[str], current_hash: str, stored_hash: str | None, expected_exit: int +) -> None: + """Test main function in check mode.""" + with ( + patch("sys.argv", ["clang_tidy_hash.py"] + args), + patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), + patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), + pytest.raises(SystemExit) as exc_info, + ): + clang_tidy_hash.main() + + assert exc_info.value.code == expected_exit + + +def test_main_update_mode(capsys: pytest.CaptureFixture[str]) -> None: + """Test main function in update mode.""" + current_hash = "abc123" + + with ( + patch("sys.argv", ["clang_tidy_hash.py", "--update"]), + patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), + patch("clang_tidy_hash.write_hash") as mock_write, + ): + clang_tidy_hash.main() + + mock_write.assert_called_once_with(current_hash) + captured = capsys.readouterr() + assert f"Hash updated: {current_hash}" in captured.out + + +@pytest.mark.parametrize( + ("current_hash", "stored_hash"), + [ + ("abc123", "def456"), # Hash changed, should update + ("abc123", None), # No stored hash, should update + ], +) +def test_main_update_if_changed_mode_update( + current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str] +) -> None: + """Test main function in update-if-changed mode when update is needed.""" + with ( + patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]), + patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), + patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), + patch("clang_tidy_hash.write_hash") as mock_write, + pytest.raises(SystemExit) as exc_info, + ): + clang_tidy_hash.main() + + assert exc_info.value.code == 0 + mock_write.assert_called_once_with(current_hash) + captured = capsys.readouterr() + assert "Clang-tidy hash updated" in captured.out + + +def test_main_update_if_changed_mode_no_update( + capsys: pytest.CaptureFixture[str], +) -> None: + """Test main function in update-if-changed mode when no update is needed.""" + current_hash = "abc123" + stored_hash = "abc123" + + with ( + patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]), + patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), + patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), + patch("clang_tidy_hash.write_hash") as mock_write, + pytest.raises(SystemExit) as exc_info, + ): + clang_tidy_hash.main() + + assert exc_info.value.code == 0 + mock_write.assert_not_called() + captured = capsys.readouterr() + assert "Clang-tidy hash unchanged" in captured.out + + +def test_main_verify_mode_success(capsys: pytest.CaptureFixture[str]) -> None: + """Test main function in verify mode when verification passes.""" + current_hash = "abc123" + stored_hash = "abc123" + + with ( + patch("sys.argv", ["clang_tidy_hash.py", "--verify"]), + patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), + patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), + ): + clang_tidy_hash.main() + captured = capsys.readouterr() + assert "Hash verification passed" in captured.out + + +@pytest.mark.parametrize( + ("current_hash", "stored_hash"), + [ + ("abc123", "def456"), # Hashes differ, verification fails + ("abc123", None), # No stored hash, verification fails + ], +) +def test_main_verify_mode_failure( + current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str] +) -> None: + """Test main function in verify mode when verification fails.""" + with ( + patch("sys.argv", ["clang_tidy_hash.py", "--verify"]), + patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), + patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), + pytest.raises(SystemExit) as exc_info, + ): + clang_tidy_hash.main() + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "ERROR: Clang-tidy configuration has changed" in captured.out + + +def test_main_default_mode(capsys: pytest.CaptureFixture[str]) -> None: + """Test main function in default mode (no arguments).""" + current_hash = "abc123" + stored_hash = "def456" + + with ( + patch("sys.argv", ["clang_tidy_hash.py"]), + patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), + patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), + ): + clang_tidy_hash.main() + + captured = capsys.readouterr() + assert f"Current hash: {current_hash}" in captured.out + assert f"Stored hash: {stored_hash}" in captured.out + assert "Match: False" in captured.out + + +def test_read_file_lines(tmp_path: Path) -> None: + """Test read_file_lines helper function.""" + test_file = tmp_path / "test.txt" + test_content = "line1\nline2\nline3\n" + test_file.write_text(test_content) + + result = clang_tidy_hash.read_file_lines(test_file) + + assert result == ["line1\n", "line2\n", "line3\n"] + + +def test_read_file_bytes(tmp_path: Path) -> None: + """Test read_file_bytes helper function.""" + test_file = tmp_path / "test.bin" + test_content = b"binary content\x00\xff" + test_file.write_bytes(test_content) + + result = clang_tidy_hash.read_file_bytes(test_file) + + assert result == test_content + + +def test_write_file_content(tmp_path: Path) -> None: + """Test write_file_content helper function.""" + test_file = tmp_path / "test.txt" + test_content = "test content" + + clang_tidy_hash.write_file_content(test_file, test_content) + + assert test_file.read_text() == test_content + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("clang-tidy==18.1.5", ("clang-tidy", "clang-tidy==18.1.5")), + ( + "clang-tidy==18.1.5 # comment", + ("clang-tidy", "clang-tidy==18.1.5 # comment"), + ), + ("some-package>=1.0,<2.0", ("some-package", "some-package>=1.0,<2.0")), + ("pkg_with-dashes==1.0", ("pkg_with-dashes", "pkg_with-dashes==1.0")), + ("# just a comment", None), + ("", None), + (" ", None), + ("invalid line without version", None), + ], +) +def test_parse_requirement_line(line: str, expected: tuple[str, str] | None) -> None: + """Test parsing individual requirement lines.""" + result = clang_tidy_hash.parse_requirement_line(line) + assert result == expected diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py new file mode 100644 index 0000000000..5a52081c48 --- /dev/null +++ b/tests/script/test_helpers.py @@ -0,0 +1,812 @@ +"""Unit tests for script/helpers.py module.""" + +import json +import os +from pathlib import Path +import subprocess +import sys +from unittest.mock import Mock, patch + +import pytest +from pytest import MonkeyPatch + +# Add the script directory to Python path so we can import helpers +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script")) +) + +import helpers # noqa: E402 + +changed_files = helpers.changed_files +filter_changed = helpers.filter_changed +get_changed_components = helpers.get_changed_components +_get_changed_files_from_command = helpers._get_changed_files_from_command +_get_pr_number_from_github_env = helpers._get_pr_number_from_github_env +_get_changed_files_github_actions = helpers._get_changed_files_github_actions +_filter_changed_ci = helpers._filter_changed_ci +_filter_changed_local = helpers._filter_changed_local +build_all_include = helpers.build_all_include +print_file_list = helpers.print_file_list + + +@pytest.mark.parametrize( + ("github_ref", "expected_pr_number"), + [ + ("refs/pull/1234/merge", "1234"), + ("refs/pull/5678/head", "5678"), + ("refs/pull/999/merge", "999"), + ("refs/heads/main", None), + ("", None), + ], +) +def test_get_pr_number_from_github_env_ref( + monkeypatch: MonkeyPatch, github_ref: str, expected_pr_number: str | None +) -> None: + """Test extracting PR number from GITHUB_REF.""" + monkeypatch.setenv("GITHUB_REF", github_ref) + # Make sure GITHUB_EVENT_PATH is not set + monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False) + + result = _get_pr_number_from_github_env() + + assert result == expected_pr_number + + +def test_get_pr_number_from_github_env_event_file( + monkeypatch: MonkeyPatch, tmp_path: Path +) -> None: + """Test extracting PR number from GitHub event file.""" + # No PR number in ref + monkeypatch.setenv("GITHUB_REF", "refs/heads/feature-branch") + + event_file = tmp_path / "event.json" + event_data = {"pull_request": {"number": 5678}} + event_file.write_text(json.dumps(event_data)) + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) + + result = _get_pr_number_from_github_env() + + assert result == "5678" + + +def test_get_pr_number_from_github_env_no_pr( + monkeypatch: MonkeyPatch, tmp_path: Path +) -> None: + """Test when no PR number is available.""" + monkeypatch.setenv("GITHUB_REF", "refs/heads/main") + + event_file = tmp_path / "event.json" + event_data = {"push": {"head_commit": {"id": "abc123"}}} + event_file.write_text(json.dumps(event_data)) + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) + + result = _get_pr_number_from_github_env() + + assert result is None + + +@pytest.mark.parametrize( + ("github_ref", "expected_pr_number"), + [ + ("refs/pull/1234/merge", "1234"), + ("refs/pull/5678/head", "5678"), + ("refs/pull/999/merge", "999"), + ], +) +def test_github_actions_pull_request_with_pr_number_in_ref( + monkeypatch: MonkeyPatch, github_ref: str, expected_pr_number: str +) -> None: + """Test PR detection via GITHUB_REF.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + monkeypatch.setenv("GITHUB_REF", github_ref) + + expected_files = ["file1.py", "file2.cpp"] + + with patch("helpers._get_changed_files_from_command") as mock_get: + mock_get.return_value = expected_files + + result = changed_files() + + mock_get.assert_called_once_with( + ["gh", "pr", "diff", expected_pr_number, "--name-only"] + ) + assert result == expected_files + + +def test_github_actions_pull_request_with_event_file( + monkeypatch: MonkeyPatch, tmp_path: Path +) -> None: + """Test PR detection via GitHub event file.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + monkeypatch.setenv("GITHUB_REF", "refs/heads/feature-branch") + + event_file = tmp_path / "event.json" + event_data = {"pull_request": {"number": 5678}} + event_file.write_text(json.dumps(event_data)) + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) + + expected_files = ["file1.py", "file2.cpp"] + + with patch("helpers._get_changed_files_from_command") as mock_get: + mock_get.return_value = expected_files + + result = changed_files() + + mock_get.assert_called_once_with(["gh", "pr", "diff", "5678", "--name-only"]) + assert result == expected_files + + +def test_github_actions_push_event(monkeypatch: MonkeyPatch) -> None: + """Test push event handling.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_EVENT_NAME", "push") + + expected_files = ["file1.py", "file2.cpp"] + + with patch("helpers._get_changed_files_from_command") as mock_get: + mock_get.return_value = expected_files + + result = changed_files() + + mock_get.assert_called_once_with(["git", "diff", "HEAD~1..HEAD", "--name-only"]) + assert result == expected_files + + +def test_get_changed_files_github_actions_pull_request( + monkeypatch: MonkeyPatch, +) -> None: + """Test _get_changed_files_github_actions for pull request event.""" + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + + expected_files = ["file1.py", "file2.cpp"] + + with ( + patch("helpers._get_pr_number_from_github_env", return_value="1234"), + patch("helpers._get_changed_files_from_command") as mock_get, + ): + mock_get.return_value = expected_files + + result = _get_changed_files_github_actions() + + mock_get.assert_called_once_with(["gh", "pr", "diff", "1234", "--name-only"]) + assert result == expected_files + + +def test_get_changed_files_github_actions_pull_request_no_pr_number( + monkeypatch: MonkeyPatch, +) -> None: + """Test _get_changed_files_github_actions when no PR number is found.""" + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + + with patch("helpers._get_pr_number_from_github_env", return_value=None): + result = _get_changed_files_github_actions() + + assert result is None + + +def test_get_changed_files_github_actions_push(monkeypatch: MonkeyPatch) -> None: + """Test _get_changed_files_github_actions for push event.""" + monkeypatch.setenv("GITHUB_EVENT_NAME", "push") + + expected_files = ["file1.py", "file2.cpp"] + + with patch("helpers._get_changed_files_from_command") as mock_get: + mock_get.return_value = expected_files + + result = _get_changed_files_github_actions() + + mock_get.assert_called_once_with(["git", "diff", "HEAD~1..HEAD", "--name-only"]) + assert result == expected_files + + +def test_get_changed_files_github_actions_push_fallback( + monkeypatch: MonkeyPatch, +) -> None: + """Test _get_changed_files_github_actions fallback for push event.""" + monkeypatch.setenv("GITHUB_EVENT_NAME", "push") + + with patch("helpers._get_changed_files_from_command") as mock_get: + mock_get.side_effect = Exception("Failed") + + result = _get_changed_files_github_actions() + + assert result is None + + +def test_get_changed_files_github_actions_other_event(monkeypatch: MonkeyPatch) -> None: + """Test _get_changed_files_github_actions for other event types.""" + monkeypatch.setenv("GITHUB_EVENT_NAME", "workflow_dispatch") + + result = _get_changed_files_github_actions() + + assert result is None + + +def test_github_actions_push_event_fallback(monkeypatch: MonkeyPatch) -> None: + """Test push event fallback to git merge-base.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_EVENT_NAME", "push") + + expected_files = ["file1.py", "file2.cpp"] + + with ( + patch("helpers._get_changed_files_from_command") as mock_get, + patch("helpers.get_output") as mock_output, + ): + # First call fails, triggering fallback + mock_get.side_effect = [ + Exception("Failed"), + expected_files, + ] + + mock_output.side_effect = [ + "origin\nupstream\n", # git remote + "abc123\n", # merge base + ] + + result = changed_files() + + assert mock_get.call_count == 2 + assert result == expected_files + + +@pytest.mark.parametrize( + ("branch", "merge_base"), + [ + (None, "abc123"), # Default branch (dev) + ("release", "def456"), + ("beta", "ghi789"), + ], +) +def test_local_development_branches( + monkeypatch: MonkeyPatch, branch: str | None, merge_base: str +) -> None: + """Test local development with different branches.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + expected_files = ["file1.py", "file2.cpp"] + + with ( + patch("helpers.get_output") as mock_output, + patch("helpers._get_changed_files_from_command") as mock_get, + ): + if branch is None: + # For default branch, helpers.get_output is called twice (git remote and merge-base) + mock_output.side_effect = [ + "origin\nupstream\n", # git remote + f"{merge_base}\n", # merge base for upstream/dev + ] + else: + # For custom branch, may need more calls if trying multiple remotes + mock_output.side_effect = [ + "origin\nupstream\n", # git remote + Exception("not found"), # upstream/{branch} may fail + f"{merge_base}\n", # merge base for origin/{branch} + ] + + mock_get.return_value = expected_files + + result = changed_files(branch) + + mock_get.assert_called_once_with(["git", "diff", merge_base, "--name-only"]) + assert result == expected_files + + +def test_local_development_no_remotes_configured(monkeypatch: MonkeyPatch) -> None: + """Test error when no git remotes are configured.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + with patch("helpers.get_output") as mock_output: + # The function calls get_output multiple times: + # 1. First to get list of remotes: git remote + # 2. Then for each remote it tries: git merge-base + # We simulate having some remotes but all merge-base attempts fail + def side_effect_func(*args): + if args == ("git", "remote"): + return "origin\nupstream\n" + else: + # All merge-base attempts fail + raise Exception("Command failed") + + mock_output.side_effect = side_effect_func + + with pytest.raises(ValueError, match="Git not configured"): + changed_files() + + +@pytest.mark.parametrize( + ("stdout", "expected"), + [ + ("file1.py\nfile2.cpp\n\n", ["file1.py", "file2.cpp"]), + ("\n\n", []), + ("single.py\n", ["single.py"]), + ( + "path/to/file.cpp\nanother/path.h\n", + ["another/path.h", "path/to/file.cpp"], + ), # Sorted + ], +) +def test_get_changed_files_from_command_successful( + stdout: str, expected: list[str] +) -> None: + """Test successful command execution with various outputs.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = stdout + + with patch("subprocess.run", return_value=mock_result): + result = _get_changed_files_from_command(["git", "diff"]) + + # Normalize paths to forward slashes for comparison + # since os.path.relpath returns OS-specific separators + normalized_result = [f.replace(os.sep, "/") for f in result] + assert normalized_result == expected + + +@pytest.mark.parametrize( + ("returncode", "stderr"), + [ + (1, "Error: command failed"), + (128, "fatal: not a git repository"), + (2, "Unknown error"), + ], +) +def test_get_changed_files_from_command_failed(returncode: int, stderr: str) -> None: + """Test command failure handling.""" + mock_result = Mock() + mock_result.returncode = returncode + mock_result.stderr = stderr + + with patch("subprocess.run", return_value=mock_result): + with pytest.raises(Exception) as exc_info: + _get_changed_files_from_command(["git", "diff"]) + assert "Command failed" in str(exc_info.value) + assert stderr in str(exc_info.value) + + +def test_get_changed_files_from_command_relative_paths() -> None: + """Test that paths are made relative to current directory.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "/some/project/file1.py\n/some/project/sub/file2.cpp\n" + + with ( + patch("subprocess.run", return_value=mock_result), + patch( + "os.path.relpath", side_effect=["file1.py", "sub/file2.cpp"] + ) as mock_relpath, + patch("os.getcwd", return_value="/some/project"), + ): + result = _get_changed_files_from_command(["git", "diff"]) + + # Check relpath was called with correct arguments + assert mock_relpath.call_count == 2 + assert result == ["file1.py", "sub/file2.cpp"] + + +@pytest.mark.parametrize( + "changed_files_list", + [ + ["esphome/core/component.h", "esphome/components/wifi/wifi.cpp"], + ["esphome/core/helpers.cpp"], + ["esphome/core/application.h", "esphome/core/defines.h"], + ], +) +def test_get_changed_components_core_files_trigger_full_scan( + changed_files_list: list[str], +) -> None: + """Test that core file changes trigger full scan without calling subprocess.""" + with patch("helpers.changed_files") as mock_changed: + mock_changed.return_value = changed_files_list + + # Should return None without calling subprocess + result = get_changed_components() + assert result is None + + +@pytest.mark.parametrize( + ("changed_files_list", "expected"), + [ + # Only component files changed + ( + ["esphome/components/wifi/wifi.cpp", "esphome/components/api/api.cpp"], + ["wifi", "api"], + ), + # Non-component files only + (["README.md", "script/clang-tidy"], []), + # Single component + (["esphome/components/mqtt/mqtt_client.cpp"], ["mqtt"]), + ], +) +def test_get_changed_components_returns_component_list( + changed_files_list: list[str], expected: list[str] +) -> None: + """Test component detection returns correct component list.""" + with patch("helpers.changed_files") as mock_changed: + mock_changed.return_value = changed_files_list + + mock_result = Mock() + mock_result.stdout = "\n".join(expected) + "\n" if expected else "\n" + + with patch("subprocess.run", return_value=mock_result): + result = get_changed_components() + assert result == expected + + +def test_get_changed_components_script_failure() -> None: + """Test fallback to full scan when script fails.""" + with patch("helpers.changed_files") as mock_changed: + mock_changed.return_value = ["esphome/components/wifi/wifi_component.cpp"] + + with patch("subprocess.run") as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, "cmd") + + result = get_changed_components() + + assert result is None # None means full scan + + +@pytest.mark.parametrize( + ("components", "all_files", "expected_files"), + [ + # Core files changed (full scan) + ( + None, + ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"], + ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"], + ), + # Specific components + ( + ["wifi", "api"], + [ + "esphome/components/wifi/wifi.cpp", + "esphome/components/wifi/wifi.h", + "esphome/components/api/api.cpp", + "esphome/components/mqtt/mqtt.cpp", + ], + [ + "esphome/components/wifi/wifi.cpp", + "esphome/components/wifi/wifi.h", + "esphome/components/api/api.cpp", + ], + ), + # No components changed + ( + [], + ["esphome/components/wifi/wifi.cpp", "script/clang-tidy"], + ["script/clang-tidy"], # Only non-component changed files + ), + ], +) +def test_filter_changed_ci_mode( + monkeypatch: MonkeyPatch, + components: list[str] | None, + all_files: list[str], + expected_files: list[str], +) -> None: + """Test filter_changed in CI mode with different component scenarios.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + + with patch("helpers.get_changed_components") as mock_components: + mock_components.return_value = components + + if components == []: + # No components changed scenario needs changed_files mock + with patch("helpers.changed_files") as mock_changed: + mock_changed.return_value = ["script/clang-tidy", "README.md"] + result = filter_changed(all_files) + else: + result = filter_changed(all_files) + + assert set(result) == set(expected_files) + + +def test_filter_changed_local_mode(monkeypatch: MonkeyPatch) -> None: + """Test filter_changed in local mode filters files directly.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + all_files = [ + "esphome/components/wifi/wifi.cpp", + "esphome/components/api/api.cpp", + "esphome/core/helpers.cpp", + ] + + with patch("helpers.changed_files") as mock_changed: + mock_changed.return_value = [ + "esphome/components/wifi/wifi.cpp", + "esphome/core/helpers.cpp", + ] + + result = filter_changed(all_files) + + # Should only include files that actually changed + expected = ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"] + assert set(result) == set(expected) + + +def test_filter_changed_component_path_parsing(monkeypatch: MonkeyPatch) -> None: + """Test correct parsing of component paths.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + + all_files = [ + "esphome/components/wifi/wifi_component.cpp", + "esphome/components/wifi_info/wifi_info_text_sensor.cpp", # Different component + "esphome/components/api/api_server.cpp", + "esphome/components/api/custom_api_device.h", + ] + + with patch("helpers.get_changed_components") as mock_components: + mock_components.return_value = ["wifi"] # Only wifi, not wifi_info + + result = filter_changed(all_files) + + # Should only include files from wifi component, not wifi_info + expected = ["esphome/components/wifi/wifi_component.cpp"] + assert result == expected + + +def test_filter_changed_prints_output( + monkeypatch: MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Test that appropriate messages are printed.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + + all_files = ["esphome/components/wifi/wifi_component.cpp"] + + with patch("helpers.get_changed_components") as mock_components: + mock_components.return_value = ["wifi"] + + filter_changed(all_files) + + # Check that output was produced (not checking exact messages) + captured = capsys.readouterr() + assert len(captured.out) > 0 + + +@pytest.mark.parametrize( + ("files", "expected_empty"), + [ + ([], True), + (["file.cpp"], False), + ], + ids=["empty_files", "non_empty_files"], +) +def test_filter_changed_empty_file_handling( + monkeypatch: MonkeyPatch, files: list[str], expected_empty: bool +) -> None: + """Test handling of empty file lists.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + + with patch("helpers.get_changed_components") as mock_components: + mock_components.return_value = ["wifi"] + + result = filter_changed(files) + + # Both cases should be empty: + # - Empty files list -> empty result + # - file.cpp doesn't match esphome/components/wifi/* pattern -> filtered out + assert len(result) == 0 + + +def test_filter_changed_ci_full_scan() -> None: + """Test _filter_changed_ci when core files changed (full scan).""" + all_files = ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"] + + with patch("helpers.get_changed_components", return_value=None): + result = _filter_changed_ci(all_files) + + # Should return all files for full scan + assert result == all_files + + +def test_filter_changed_ci_no_components_changed() -> None: + """Test _filter_changed_ci when no components changed.""" + all_files = ["esphome/components/wifi/wifi.cpp", "script/clang-tidy", "README.md"] + + with ( + patch("helpers.get_changed_components", return_value=[]), + patch("helpers.changed_files", return_value=["script/clang-tidy", "README.md"]), + ): + result = _filter_changed_ci(all_files) + + # Should only include non-component files that changed + assert set(result) == {"script/clang-tidy", "README.md"} + + +def test_filter_changed_ci_specific_components() -> None: + """Test _filter_changed_ci with specific components changed.""" + all_files = [ + "esphome/components/wifi/wifi.cpp", + "esphome/components/wifi/wifi.h", + "esphome/components/api/api.cpp", + "esphome/components/mqtt/mqtt.cpp", + ] + + with patch("helpers.get_changed_components", return_value=["wifi", "api"]): + result = _filter_changed_ci(all_files) + + # Should include all files from wifi and api components + expected = [ + "esphome/components/wifi/wifi.cpp", + "esphome/components/wifi/wifi.h", + "esphome/components/api/api.cpp", + ] + assert set(result) == set(expected) + + +def test_filter_changed_local() -> None: + """Test _filter_changed_local filters based on git changes.""" + all_files = [ + "esphome/components/wifi/wifi.cpp", + "esphome/components/api/api.cpp", + "esphome/core/helpers.cpp", + ] + + with patch("helpers.changed_files") as mock_changed: + mock_changed.return_value = [ + "esphome/components/wifi/wifi.cpp", + "esphome/core/helpers.cpp", + ] + + result = _filter_changed_local(all_files) + + # Should only include files that actually changed + expected = ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"] + assert set(result) == set(expected) + + +def test_build_all_include_with_git(tmp_path: Path) -> None: + """Test build_all_include using git ls-files.""" + # Mock git output + git_output = "esphome/core/component.h\nesphome/components/wifi/wifi.h\nesphome/components/api/api.h\n" + + mock_proc = Mock() + mock_proc.returncode = 0 + mock_proc.stdout = git_output + + with ( + patch("subprocess.run", return_value=mock_proc), + patch("helpers.temp_header_file", str(tmp_path / "all-include.cpp")), + ): + build_all_include() + + # Check the generated file + include_file = tmp_path / "all-include.cpp" + assert include_file.exists() + + content = include_file.read_text() + expected_lines = [ + '#include "esphome/components/api/api.h"', + '#include "esphome/components/wifi/wifi.h"', + '#include "esphome/core/component.h"', + "", # Empty line at end + ] + assert content == "\n".join(expected_lines) + + +def test_build_all_include_empty_output(tmp_path: Path) -> None: + """Test build_all_include with empty git output.""" + # Mock git returning empty output + mock_proc = Mock() + mock_proc.returncode = 0 + mock_proc.stdout = "" + + with ( + patch("subprocess.run", return_value=mock_proc), + patch("helpers.temp_header_file", str(tmp_path / "all-include.cpp")), + ): + build_all_include() + + # Check the generated file + include_file = tmp_path / "all-include.cpp" + assert include_file.exists() + + content = include_file.read_text() + # When git output is empty, the list comprehension filters out empty strings, + # then we append "" to get [""], which joins to just "" + assert content == "" + + +def test_build_all_include_creates_directory(tmp_path: Path) -> None: + """Test that build_all_include creates the temp directory if needed.""" + # Use a subdirectory that doesn't exist + temp_file = tmp_path / "subdir" / "all-include.cpp" + + mock_proc = Mock() + mock_proc.returncode = 0 + mock_proc.stdout = "esphome/core/test.h\n" + + with ( + patch("subprocess.run", return_value=mock_proc), + patch("helpers.temp_header_file", str(temp_file)), + ): + build_all_include() + + # Check that directory was created + assert temp_file.parent.exists() + assert temp_file.exists() + + +def test_print_file_list_empty(capsys: pytest.CaptureFixture[str]) -> None: + """Test printing an empty file list.""" + print_file_list([], "Test Files:") + captured = capsys.readouterr() + + assert "Test Files:" in captured.out + assert "No files to check!" in captured.out + + +def test_print_file_list_small(capsys: pytest.CaptureFixture[str]) -> None: + """Test printing a small list of files (less than max_files).""" + files = ["file1.cpp", "file2.cpp", "file3.cpp"] + print_file_list(files, "Test Files:", max_files=20) + captured = capsys.readouterr() + + assert "Test Files:" in captured.out + assert " file1.cpp" in captured.out + assert " file2.cpp" in captured.out + assert " file3.cpp" in captured.out + assert "... and" not in captured.out + + +def test_print_file_list_exact_max_files(capsys: pytest.CaptureFixture[str]) -> None: + """Test printing exactly max_files number of files.""" + files = [f"file{i}.cpp" for i in range(20)] + print_file_list(files, "Test Files:", max_files=20) + captured = capsys.readouterr() + + # All files should be shown + for i in range(20): + assert f" file{i}.cpp" in captured.out + assert "... and" not in captured.out + + +def test_print_file_list_large(capsys: pytest.CaptureFixture[str]) -> None: + """Test printing a large list of files (more than max_files).""" + files = [f"file{i:03d}.cpp" for i in range(50)] + print_file_list(files, "Test Files:", max_files=20) + captured = capsys.readouterr() + + assert "Test Files:" in captured.out + # First 10 files should be shown (sorted) + for i in range(10): + assert f" file{i:03d}.cpp" in captured.out + # Files 10-49 should not be shown + assert " file010.cpp" not in captured.out + assert " file049.cpp" not in captured.out + # Should show count of remaining files + assert "... and 40 more files" in captured.out + + +def test_print_file_list_unsorted(capsys: pytest.CaptureFixture[str]) -> None: + """Test that files are sorted before printing.""" + files = ["z_file.cpp", "a_file.cpp", "m_file.cpp"] + print_file_list(files, "Test Files:", max_files=20) + captured = capsys.readouterr() + + lines = captured.out.strip().split("\n") + # Check order in output + assert lines[1] == " a_file.cpp" + assert lines[2] == " m_file.cpp" + assert lines[3] == " z_file.cpp" + + +def test_print_file_list_custom_max_files(capsys: pytest.CaptureFixture[str]) -> None: + """Test with custom max_files parameter.""" + files = [f"file{i}.cpp" for i in range(15)] + print_file_list(files, "Test Files:", max_files=10) + captured = capsys.readouterr() + + # Should truncate after 10 files + assert "... and 5 more files" in captured.out + + +def test_print_file_list_default_title(capsys: pytest.CaptureFixture[str]) -> None: + """Test with default title.""" + print_file_list(["test.cpp"]) + captured = capsys.readouterr() + + assert "Files:" in captured.out + assert " test.cpp" in captured.out From c1a994b1d997e71cc655cd3c38cabf6b6e18391b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 9 Jul 2025 11:11:42 -1000 Subject: [PATCH 24/51] Fix another race in the string lifetime scheduler test (#9399) --- .../string_lifetime_component.cpp | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp index ea386881b2..8c3f665f19 100644 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp @@ -23,19 +23,6 @@ void SchedulerStringLifetimeComponent::run_string_lifetime_test() { test_vector_reallocation(); test_string_move_semantics(); test_lambda_capture_lifetime(); - - // Schedule final check - this->set_timeout("final_check", 200, [this]() { - ESP_LOGI(TAG, "Tests passed: %d", this->tests_passed_); - ESP_LOGI(TAG, "Tests failed: %d", this->tests_failed_); - - if (this->tests_failed_ == 0) { - ESP_LOGI(TAG, "SUCCESS: All string lifetime tests passed!"); - } else { - ESP_LOGE(TAG, "FAILURE: %d string lifetime tests failed!", this->tests_failed_); - } - ESP_LOGI(TAG, "String lifetime tests complete"); - }); } void SchedulerStringLifetimeComponent::run_test1() { @@ -69,7 +56,6 @@ void SchedulerStringLifetimeComponent::run_test5() { } void SchedulerStringLifetimeComponent::run_final_check() { - ESP_LOGI(TAG, "String lifetime tests complete"); ESP_LOGI(TAG, "Tests passed: %d", this->tests_passed_); ESP_LOGI(TAG, "Tests failed: %d", this->tests_failed_); @@ -78,6 +64,7 @@ void SchedulerStringLifetimeComponent::run_final_check() { } else { ESP_LOGE(TAG, "FAILURE: %d string lifetime tests failed!", this->tests_failed_); } + ESP_LOGI(TAG, "String lifetime tests complete"); } void SchedulerStringLifetimeComponent::test_temporary_string_lifetime() { From 3d9c9778265a0bb0b9034ac78485634c5dd9768a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 9 Jul 2025 20:18:01 -0400 Subject: [PATCH 25/51] [esp32_touch] Fix touch v1 (#9414) --- .../components/esp32_touch/esp32_touch_v1.cpp | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/esphome/components/esp32_touch/esp32_touch_v1.cpp b/esphome/components/esp32_touch/esp32_touch_v1.cpp index 6f05610ed6..c3d43c6bbf 100644 --- a/esphome/components/esp32_touch/esp32_touch_v1.cpp +++ b/esphome/components/esp32_touch/esp32_touch_v1.cpp @@ -109,6 +109,7 @@ void ESP32TouchComponent::loop() { // Only publish if state changed - this filters out repeated events if (new_state != child->last_state_) { + child->initial_state_published_ = true; child->last_state_ = new_state; child->publish_state(new_state); // Original ESP32: ISR only fires when touched, release is detected by timeout @@ -175,6 +176,9 @@ void ESP32TouchComponent::on_shutdown() { void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) { ESP32TouchComponent *component = static_cast(arg); + uint32_t mask = 0; + touch_ll_read_trigger_status_mask(&mask); + touch_ll_clear_trigger_status_mask(); touch_pad_clear_status(); // INTERRUPT BEHAVIOR: On ESP32 v1 hardware, the interrupt fires when ANY configured @@ -184,6 +188,11 @@ void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) { // as any pad remains touched. This allows us to detect both new touches and // continued touches, but releases must be detected by timeout in the main loop. + // IMPORTANT: ESP32 v1 touch detection logic - INVERTED compared to v2! + // ESP32 v1: Touch is detected when capacitance INCREASES, causing the measured value to DECREASE + // Therefore: touched = (value < threshold) + // This is opposite to ESP32-S2/S3 v2 where touched = (value > threshold) + // Process all configured pads to check their current state // Note: ESP32 v1 doesn't tell us which specific pad triggered the interrupt, // so we must scan all configured pads to find which ones were touched @@ -201,19 +210,12 @@ void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) { value = touch_ll_read_raw_data(pad); } - // Skip pads with 0 value - they haven't been measured in this cycle - // This is important: not all pads are measured every interrupt cycle, - // only those that the hardware has updated - if (value == 0) { + // Skip pads that aren’t in the trigger mask + bool is_touched = (mask >> pad) & 1; + if (!is_touched) { continue; } - // IMPORTANT: ESP32 v1 touch detection logic - INVERTED compared to v2! - // ESP32 v1: Touch is detected when capacitance INCREASES, causing the measured value to DECREASE - // Therefore: touched = (value < threshold) - // This is opposite to ESP32-S2/S3 v2 where touched = (value > threshold) - bool is_touched = value < child->get_threshold(); - // Always send the current state - the main loop will filter for changes // We send both touched and untouched states because the ISR doesn't // track previous state (to keep ISR fast and simple) From ff836a843469d50a2fdfb0283281458c0a300d12 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 9 Jul 2025 14:40:10 -1000 Subject: [PATCH 26/51] Fix PlatformIO cache in CI by adding platformio.ini hash to cache key (#9411) --- .github/workflows/ci.yml | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddfda042b5..029011b69b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -349,28 +349,14 @@ jobs: uses: actions/cache@v4.2.3 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }} + key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' uses: actions/cache/restore@v4.2.3 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }} - - - name: Cache platformio libdeps - if: github.ref == 'refs/heads/dev' - uses: actions/cache@v4.2.3 - with: - path: .pio/libdeps - key: pio-libdeps-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - - - name: Cache platformio libdeps - if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@v4.2.3 - with: - path: .pio/libdeps - key: pio-libdeps-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Register problem matchers run: | From e9c7596e00b3b90171592318bc5a58480a0ec81a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 9 Jul 2025 14:41:59 -1000 Subject: [PATCH 27/51] Fix clang-tidy triggering full scan on Python-only core file changes (#9412) --- script/helpers.py | 21 ++++++++++------- tests/script/test_helpers.py | 45 ++++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/script/helpers.py b/script/helpers.py index 4528763ab3..5dbc7a32cc 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -168,21 +168,26 @@ def get_changed_components() -> list[str] | None: """Get list of changed components using list-components.py script. This function: - 1. First checks if any core files (esphome/core/*) changed - if so, returns None + 1. First checks if any core C++/header files (esphome/core/*.{cpp,h,hpp,cc,cxx,c}) changed - if so, returns None 2. Otherwise delegates to ./script/list-components.py --changed which: - Analyzes all changed files - Determines which components are affected (including dependencies) - Returns a list of component names that need to be checked Returns: - - None: Core files changed, need full scan + - None: Core C++/header files changed, need full scan - Empty list: No components changed (only non-component files changed) - List of strings: Names of components that need checking (e.g., ["wifi", "mqtt"]) """ - # Check if any core files changed first + # Check if any core C++ or header files changed first changed = changed_files() - if any(f.startswith("esphome/core/") for f in changed): - print("Core files changed - will run full clang-tidy scan") + core_cpp_changed = any( + f.startswith("esphome/core/") + and f.endswith((".cpp", ".h", ".hpp", ".cc", ".cxx", ".c")) + for f in changed + ) + if core_cpp_changed: + print("Core C++/header files changed - will run full clang-tidy scan") return None # Use list-components.py to get changed components @@ -207,10 +212,10 @@ def _filter_changed_ci(files: list[str]) -> list[str]: This function implements intelligent filtering to reduce CI runtime by only checking files that could be affected by the changes. It handles three scenarios: - 1. Core files changed (returns None from get_changed_components): - - Triggered when any file in esphome/core/ is modified + 1. Core C++/header files changed (returns None from get_changed_components): + - Triggered when any C++/header file in esphome/core/ is modified - Action: Check ALL files (full scan) - - Reason: Core files are used throughout the codebase + - Reason: Core C++/header files are used throughout the codebase 2. No components changed (returns empty list from get_changed_components): - Triggered when only non-component files changed (e.g., scripts, configs) diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 5a52081c48..bbebdd79c8 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -394,10 +394,10 @@ def test_get_changed_files_from_command_relative_paths() -> None: ["esphome/core/application.h", "esphome/core/defines.h"], ], ) -def test_get_changed_components_core_files_trigger_full_scan( +def test_get_changed_components_core_cpp_files_trigger_full_scan( changed_files_list: list[str], ) -> None: - """Test that core file changes trigger full scan without calling subprocess.""" + """Test that core C++/header file changes trigger full scan without calling subprocess.""" with patch("helpers.changed_files") as mock_changed: mock_changed.return_value = changed_files_list @@ -406,6 +406,43 @@ def test_get_changed_components_core_files_trigger_full_scan( assert result is None +def test_get_changed_components_core_python_files_no_full_scan() -> None: + """Test that core Python file changes do NOT trigger full scan.""" + changed_files_list = [ + "esphome/core/__init__.py", + "esphome/core/config.py", + "esphome/components/wifi/wifi.cpp", + ] + + with patch("helpers.changed_files") as mock_changed: + mock_changed.return_value = changed_files_list + + mock_result = Mock() + mock_result.stdout = "wifi\n" + + with patch("subprocess.run", return_value=mock_result): + result = get_changed_components() + # Should NOT return None - should call list-components.py + assert result == ["wifi"] + + +def test_get_changed_components_mixed_core_files_with_cpp() -> None: + """Test that mixed Python and C++ core files still trigger full scan due to C++ file.""" + changed_files_list = [ + "esphome/core/__init__.py", + "esphome/core/config.py", + "esphome/core/helpers.cpp", # This C++ file should trigger full scan + "esphome/components/wifi/wifi.cpp", + ] + + with patch("helpers.changed_files") as mock_changed: + mock_changed.return_value = changed_files_list + + # Should return None without calling subprocess due to helpers.cpp + result = get_changed_components() + assert result is None + + @pytest.mark.parametrize( ("changed_files_list", "expected"), [ @@ -451,7 +488,7 @@ def test_get_changed_components_script_failure() -> None: @pytest.mark.parametrize( ("components", "all_files", "expected_files"), [ - # Core files changed (full scan) + # Core C++/header files changed (full scan) ( None, ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"], @@ -591,7 +628,7 @@ def test_filter_changed_empty_file_handling( def test_filter_changed_ci_full_scan() -> None: - """Test _filter_changed_ci when core files changed (full scan).""" + """Test _filter_changed_ci when core C++/header files changed (full scan).""" all_files = ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"] with patch("helpers.get_changed_components", return_value=None): From 97dd96b60d259063e8ad76fd1bcbd13a5641b3f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 9 Jul 2025 14:42:24 -1000 Subject: [PATCH 28/51] Implement shared PlatformIO cache for integration tests (#9413) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/integration/conftest.py | 78 +++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 12 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index aead6a73af..e3ba09de43 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -5,12 +5,14 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, Callable, Generator from contextlib import AbstractAsyncContextManager, asynccontextmanager +import fcntl import logging import os from pathlib import Path import platform import signal import socket +import subprocess import sys import tempfile from typing import TextIO @@ -50,6 +52,66 @@ if platform.system() == "Windows": import pty # not available on Windows +def _get_platformio_env(cache_dir: Path) -> dict[str, str]: + """Get environment variables for PlatformIO with shared cache.""" + env = os.environ.copy() + env["PLATFORMIO_CORE_DIR"] = str(cache_dir) + env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache") + env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") + return env + + +@pytest.fixture(scope="session") +def shared_platformio_cache() -> Generator[Path]: + """Initialize a shared PlatformIO cache for all integration tests.""" + # Use a dedicated directory for integration tests to avoid conflicts + test_cache_dir = Path.home() / ".esphome-integration-tests" + cache_dir = test_cache_dir / "platformio" + + # Use a lock file in the home directory to ensure only one process initializes the cache + # This is needed when running with pytest-xdist + # The lock file must be in a directory that already exists to avoid race conditions + lock_file = Path.home() / ".esphome-integration-tests-init.lock" + + # Always acquire the lock to ensure cache is ready before proceeding + with open(lock_file, "w") as lock_fd: + fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) + + # Check if cache needs initialization while holding the lock + if not cache_dir.exists() or not any(cache_dir.iterdir()): + # Create the test cache directory if it doesn't exist + test_cache_dir.mkdir(exist_ok=True) + + with tempfile.TemporaryDirectory() as tmpdir: + # Create a basic host config + init_dir = Path(tmpdir) + config_path = init_dir / "cache_init.yaml" + config_path.write_text("""esphome: + name: cache-init +host: +api: + encryption: + key: "IIevImVI42I0FGos5nLqFK91jrJehrgidI0ArwMLr8w=" +logger: +""") + + # Run compilation to populate the cache + # We must succeed here to avoid race conditions where multiple + # tests try to populate the same cache directory simultaneously + env = _get_platformio_env(cache_dir) + + subprocess.run( + ["esphome", "compile", str(config_path)], + check=True, + cwd=init_dir, + env=env, + ) + + # Lock is held until here, ensuring cache is fully populated before any test proceeds + + yield cache_dir + + @pytest.fixture(scope="module", autouse=True) def enable_aioesphomeapi_debug_logging(): """Enable debug logging for aioesphomeapi to help diagnose connection issues.""" @@ -161,22 +223,14 @@ async def write_yaml_config( @pytest_asyncio.fixture async def compile_esphome( integration_test_dir: Path, + shared_platformio_cache: Path, ) -> AsyncGenerator[CompileFunction]: """Compile an ESPHome configuration and return the binary path.""" async def _compile(config_path: Path) -> Path: - # Create a unique PlatformIO directory for this test to avoid race conditions - platformio_dir = integration_test_dir / ".platformio" - platformio_dir.mkdir(parents=True, exist_ok=True) - - # Create cache directory as well - platformio_cache_dir = platformio_dir / ".cache" - platformio_cache_dir.mkdir(parents=True, exist_ok=True) - - # Set up environment with isolated PlatformIO directories - env = os.environ.copy() - env["PLATFORMIO_CORE_DIR"] = str(platformio_dir) - env["PLATFORMIO_CACHE_DIR"] = str(platformio_cache_dir) + # Use the shared PlatformIO cache for faster compilation + # This avoids re-downloading dependencies for each test + env = _get_platformio_env(shared_platformio_cache) # Retry compilation up to 3 times if we get a segfault max_retries = 3 From 926e4fa3e1686d7e1bcae5fff4053f9a5e74453d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 9 Jul 2025 14:43:49 -1000 Subject: [PATCH 29/51] Fix Python cache for all pytest CI jobs (#9415) --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 029011b69b..a753ac75ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,6 +224,12 @@ jobs: uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} + - name: Save Python virtual environment cache + if: github.ref == 'refs/heads/dev' && (matrix.os == 'windows-latest' || matrix.os == 'macOS-latest') + uses: actions/cache/save@v4.2.3 + with: + path: venv + key: ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ needs.common.outputs.cache-key }} integration-tests: name: Run integration tests From 9a1edaa4f483f365b1ac57cb3f92eb8fc6d9ede2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 9 Jul 2025 15:21:21 -1000 Subject: [PATCH 30/51] Fix Python cache key mismatch for all pytest jobs (#9417) --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a753ac75ea..811cc3fe3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,6 +204,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Restore Python + id: restore-python uses: ./.github/actions/restore-python with: python-version: ${{ matrix.python-version }} @@ -225,11 +226,11 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache - if: github.ref == 'refs/heads/dev' && (matrix.os == 'windows-latest' || matrix.os == 'macOS-latest') + if: github.ref == 'refs/heads/dev' uses: actions/cache/save@v4.2.3 with: path: venv - key: ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ needs.common.outputs.cache-key }} + key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} integration-tests: name: Run integration tests From 33d48732aa1c6b7c2c4f7e5cb48faa95e1f03bcf Mon Sep 17 00:00:00 2001 From: Andrew Klaus <2087223+precurse@users.noreply.github.com> Date: Wed, 9 Jul 2025 19:50:01 -0600 Subject: [PATCH 31/51] Adding support for Airthings Wave Gen2 (#8460) Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- .../airthings_wave_plus/__init__.py | 2 +- .../airthings_wave_plus.cpp | 26 +++++- .../airthings_wave_plus/airthings_wave_plus.h | 11 ++- .../components/airthings_wave_plus/sensor.py | 83 ++++++++++++------- 5 files changed, 89 insertions(+), 35 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 9b4681fcf2..2975080ba9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -28,7 +28,7 @@ esphome/components/aic3204/* @kbx81 esphome/components/airthings_ble/* @jeromelaban esphome/components/airthings_wave_base/* @jeromelaban @kpfleming @ncareau esphome/components/airthings_wave_mini/* @ncareau -esphome/components/airthings_wave_plus/* @jeromelaban +esphome/components/airthings_wave_plus/* @jeromelaban @precurse esphome/components/alarm_control_panel/* @grahambrown11 @hwstar esphome/components/alpha3/* @jan-hofmeier esphome/components/am2315c/* @swoboda1337 diff --git a/esphome/components/airthings_wave_plus/__init__.py b/esphome/components/airthings_wave_plus/__init__.py index 1aff461edd..e26bfd471b 100644 --- a/esphome/components/airthings_wave_plus/__init__.py +++ b/esphome/components/airthings_wave_plus/__init__.py @@ -1 +1 @@ -CODEOWNERS = ["@jeromelaban"] +CODEOWNERS = ["@jeromelaban", "@precurse"] diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp b/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp index 8c8c514fdb..5ed62fff62 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp @@ -73,11 +73,29 @@ void AirthingsWavePlus::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); } -AirthingsWavePlus::AirthingsWavePlus() { - this->service_uuid_ = espbt::ESPBTUUID::from_raw(SERVICE_UUID); - this->sensors_data_characteristic_uuid_ = espbt::ESPBTUUID::from_raw(CHARACTERISTIC_UUID); +void AirthingsWavePlus::setup() { + const char *service_uuid; + const char *characteristic_uuid; + const char *access_control_point_characteristic_uuid; + + // Change UUIDs for Wave Radon Gen2 + switch (this->wave_device_type_) { + case WaveDeviceType::WAVE_GEN2: + service_uuid = SERVICE_UUID_WAVE_RADON_GEN2; + characteristic_uuid = CHARACTERISTIC_UUID_WAVE_RADON_GEN2; + access_control_point_characteristic_uuid = ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID_WAVE_RADON_GEN2; + break; + default: + // Wave Plus + service_uuid = SERVICE_UUID; + characteristic_uuid = CHARACTERISTIC_UUID; + access_control_point_characteristic_uuid = ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID; + } + + this->service_uuid_ = espbt::ESPBTUUID::from_raw(service_uuid); + this->sensors_data_characteristic_uuid_ = espbt::ESPBTUUID::from_raw(characteristic_uuid); this->access_control_point_characteristic_uuid_ = - espbt::ESPBTUUID::from_raw(ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID); + espbt::ESPBTUUID::from_raw(access_control_point_characteristic_uuid); } } // namespace airthings_wave_plus diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.h b/esphome/components/airthings_wave_plus/airthings_wave_plus.h index bd7a40ef8b..c978a9af92 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.h +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.h @@ -9,13 +9,20 @@ namespace airthings_wave_plus { namespace espbt = esphome::esp32_ble_tracker; +enum WaveDeviceType : uint8_t { WAVE_PLUS = 0, WAVE_GEN2 = 1 }; + static const char *const SERVICE_UUID = "b42e1c08-ade7-11e4-89d3-123b93f75cba"; static const char *const CHARACTERISTIC_UUID = "b42e2a68-ade7-11e4-89d3-123b93f75cba"; static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID = "b42e2d06-ade7-11e4-89d3-123b93f75cba"; +static const char *const SERVICE_UUID_WAVE_RADON_GEN2 = "b42e4a8e-ade7-11e4-89d3-123b93f75cba"; +static const char *const CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e4dcc-ade7-11e4-89d3-123b93f75cba"; +static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = + "b42e50d8-ade7-11e4-89d3-123b93f75cba"; + class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { public: - AirthingsWavePlus(); + void setup() override; void dump_config() override; @@ -23,12 +30,14 @@ class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { void set_radon_long_term(sensor::Sensor *radon_long_term) { radon_long_term_sensor_ = radon_long_term; } void set_co2(sensor::Sensor *co2) { co2_sensor_ = co2; } void set_illuminance(sensor::Sensor *illuminance) { illuminance_sensor_ = illuminance; } + void set_device_type(WaveDeviceType wave_device_type) { wave_device_type_ = wave_device_type; } protected: bool is_valid_radon_value_(uint16_t radon); bool is_valid_co2_value_(uint16_t co2); void read_sensors(uint8_t *raw_value, uint16_t value_len) override; + WaveDeviceType wave_device_type_{WaveDeviceType::WAVE_PLUS}; sensor::Sensor *radon_sensor_{nullptr}; sensor::Sensor *radon_long_term_sensor_{nullptr}; diff --git a/esphome/components/airthings_wave_plus/sensor.py b/esphome/components/airthings_wave_plus/sensor.py index e0e90735f0..a12c70f04c 100644 --- a/esphome/components/airthings_wave_plus/sensor.py +++ b/esphome/components/airthings_wave_plus/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( CONF_ILLUMINANCE, CONF_RADON, CONF_RADON_LONG_TERM, + CONF_TVOC, DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_ILLUMINANCE, ICON_RADIOACTIVE, @@ -15,6 +16,7 @@ from esphome.const import ( UNIT_LUX, UNIT_PARTS_PER_MILLION, ) +from esphome.types import ConfigType DEPENDENCIES = airthings_wave_base.DEPENDENCIES @@ -25,35 +27,59 @@ AirthingsWavePlus = airthings_wave_plus_ns.class_( "AirthingsWavePlus", airthings_wave_base.AirthingsWaveBase ) +CONF_DEVICE_TYPE = "device_type" +WaveDeviceType = airthings_wave_plus_ns.enum("WaveDeviceType") +DEVICE_TYPES = { + "WAVE_PLUS": WaveDeviceType.WAVE_PLUS, + "WAVE_GEN2": WaveDeviceType.WAVE_GEN2, +} -CONFIG_SCHEMA = airthings_wave_base.BASE_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(AirthingsWavePlus), - cv.Optional(CONF_RADON): sensor.sensor_schema( - unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, - icon=ICON_RADIOACTIVE, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_RADON_LONG_TERM): sensor.sensor_schema( - unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, - icon=ICON_RADIOACTIVE, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_CO2): sensor.sensor_schema( - unit_of_measurement=UNIT_PARTS_PER_MILLION, - accuracy_decimals=0, - device_class=DEVICE_CLASS_CARBON_DIOXIDE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( - unit_of_measurement=UNIT_LUX, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, - state_class=STATE_CLASS_MEASUREMENT, - ), - } + +def validate_wave_gen2_config(config: ConfigType) -> ConfigType: + """Validate that Wave Gen2 devices don't have CO2 or TVOC sensors.""" + if config[CONF_DEVICE_TYPE] == "WAVE_GEN2": + if CONF_CO2 in config: + raise cv.Invalid("Wave Gen2 devices do not support CO2 sensor") + # Check for TVOC in the base schema config + if CONF_TVOC in config: + raise cv.Invalid("Wave Gen2 devices do not support TVOC sensor") + return config + + +CONFIG_SCHEMA = cv.All( + airthings_wave_base.BASE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(AirthingsWavePlus), + cv.Optional(CONF_RADON): sensor.sensor_schema( + unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, + icon=ICON_RADIOACTIVE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RADON_LONG_TERM): sensor.sensor_schema( + unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, + icon=ICON_RADIOACTIVE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_DEVICE_TYPE, default="WAVE_PLUS"): cv.enum( + DEVICE_TYPES, upper=True + ), + } + ), + validate_wave_gen2_config, ) @@ -73,3 +99,4 @@ async def to_code(config): if config_illuminance := config.get(CONF_ILLUMINANCE): sens = await sensor.new_sensor(config_illuminance) cg.add(var.set_illuminance(sens)) + cg.add(var.set_device_type(config[CONF_DEVICE_TYPE])) From e2c60f5384a0e9c92adb2a1c91fd6ee4d42f2d8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 9 Jul 2025 16:37:03 -1000 Subject: [PATCH 32/51] Fix Windows virtual environment activation in CI workflows (#9420) --- .github/actions/restore-python/action.yml | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 082539adaa..3a7b301b60 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -41,7 +41,7 @@ runs: shell: bash run: | python -m venv venv - ./venv/Scripts/activate + source ./venv/Scripts/activate python --version pip install -r requirements.txt -r requirements_test.txt pip install -e . diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 811cc3fe3f..6e151e633f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,7 +214,7 @@ jobs: - name: Run pytest if: matrix.os == 'windows-latest' run: | - ./venv/Scripts/activate + . ./venv/Scripts/activate.ps1 pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Run pytest if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' From fc59c088001c0ea8208167cef0c0c5b95929a13b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 9 Jul 2025 16:37:48 -1000 Subject: [PATCH 33/51] Fix clang-tidy not finding changed files on squash-merge commits (#9421) --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e151e633f..08d64243b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -344,6 +344,9 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 + with: + # Need history for HEAD~1 to work for checking changed files + fetch-depth: 2 - name: Restore Python uses: ./.github/actions/restore-python From a240f0af901ac0727b6c116ea62438c47cb4a6af Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 9 Jul 2025 22:49:36 -0400 Subject: [PATCH 34/51] [esp32] Set lib_compat_mode to strict (#9408) --- esphome/components/esp32/__init__.py | 1 + esphome/components/esp8266/__init__.py | 1 + esphome/components/host/__init__.py | 1 + esphome/components/libretiny/__init__.py | 1 + esphome/components/rp2040/__init__.py | 1 + platformio.ini | 1 + 6 files changed, 6 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8408f902ef..fdc469e419 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -707,6 +707,7 @@ async def to_code(config): cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[config[CONF_VARIANT]]) cg.add_platformio_option("lib_ldf_mode", "off") + cg.add_platformio_option("lib_compat_mode", "strict") framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 81daad8c56..01b20bdcb1 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -180,6 +180,7 @@ async def to_code(config): cg.add(esp8266_ns.setup_preferences()) cg.add_platformio_option("lib_ldf_mode", "off") + cg.add_platformio_option("lib_compat_mode", "strict") cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_build_flag("-DUSE_ESP8266") diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index d3dbcba6ed..a67d73fbb7 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -45,3 +45,4 @@ async def to_code(config): cg.add_define("ESPHOME_BOARD", "host") cg.add_platformio_option("platform", "platformio/native") cg.add_platformio_option("lib_ldf_mode", "off") + cg.add_platformio_option("lib_compat_mode", "strict") diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 149e5d1179..d5a5dd3ee3 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -268,6 +268,7 @@ async def component_to_code(config): # disable library compatibility checks cg.add_platformio_option("lib_ldf_mode", "off") + cg.add_platformio_option("lib_compat_mode", "strict") # include in every file cg.add_platformio_option("build_src_flags", "-include Arduino.h") # dummy version code diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index ecbeb83bb4..11ed97831e 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -165,6 +165,7 @@ async def to_code(config): # Allow LDF to properly discover dependency including those in preprocessor # conditionals cg.add_platformio_option("lib_ldf_mode", "chain+") + cg.add_platformio_option("lib_compat_mode", "strict") cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_build_flag("-DUSE_RP2040") cg.set_cpp_standard("gnu++20") diff --git a/platformio.ini b/platformio.ini index 0d67e23222..7f10f0f51f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -61,6 +61,7 @@ src_filter = +<../tests/dummy_main.cpp> +<../.temp/all-include.cpp> lib_ldf_mode = off +lib_compat_mode = strict ; This are common settings for all Arduino-framework based environments. [common:arduino] From 7d92499e4cfa0603b5de302771dea43432f16aba Mon Sep 17 00:00:00 2001 From: "@RubenKelevra" Date: Thu, 10 Jul 2025 05:01:21 +0200 Subject: [PATCH 35/51] debug: bufferoverflow mitigation in DebugComponent::on_shutdown() (#9422) --- esphome/components/debug/debug_esp32.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index e48a4941b3..37990aeec5 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -53,6 +53,7 @@ void DebugComponent::on_shutdown() { auto pref = global_preferences->make_preference(REBOOT_MAX_LEN, fnv1_hash(REBOOT_KEY + App.get_name())); if (component != nullptr) { strncpy(buffer, component->get_component_source(), REBOOT_MAX_LEN - 1); + buffer[REBOOT_MAX_LEN - 1] = '\0'; } ESP_LOGD(TAG, "Storing reboot source: %s", buffer); pref.save(&buffer); @@ -68,6 +69,7 @@ std::string DebugComponent::get_reset_reason_() { auto pref = global_preferences->make_preference(REBOOT_MAX_LEN, fnv1_hash(REBOOT_KEY + App.get_name())); char buffer[REBOOT_MAX_LEN]{}; if (pref.load(&buffer)) { + buffer[REBOOT_MAX_LEN - 1] = '\0'; reset_reason = "Reboot request from " + std::string(buffer); } } From 16bb81814ca55b23cbd00987a528e147b4521152 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 10 Jul 2025 14:14:42 +1000 Subject: [PATCH 36/51] [config] Add bitrate validator (#9423) --- esphome/config_validation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 09b132a458..b1691fa43e 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1055,6 +1055,7 @@ def float_with_unit(quantity, regex_suffix, optional_unit=False): return validator +bps = float_with_unit("bits per second", "(bps|bits/s|bit/s)?") frequency = float_with_unit("frequency", "(Hz|HZ|hz)?") resistance = float_with_unit("resistance", "(Ω|Ω|ohm|Ohm|OHM)?") current = float_with_unit("current", "(a|A|amp|Amp|amps|Amps|ampere|Ampere)?") From 2be4951ad948d8e70ed7333d3cb3a3b8337f4a8a Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Thu, 10 Jul 2025 01:24:39 -0700 Subject: [PATCH 37/51] [esp32] remove debug log (#9424) --- esphome/components/esp32/gpio.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index b554b6d09c..27572063ca 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -114,7 +114,6 @@ void ESP32InternalGPIOPin::setup() { if (flags_ & gpio::FLAG_OUTPUT) { gpio_set_drive_capability(pin_, drive_strength_); } - ESP_LOGD(TAG, "rtc: %d", SOC_GPIO_SUPPORT_RTC_INDEPENDENT); } void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { From 0d94246858def0fa2187d9c015a6a0a613f51de6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 9 Jul 2025 22:34:01 -1000 Subject: [PATCH 38/51] Exclude internal entities from name uniqueness validation (#9410) --- esphome/core/entity_helpers.py | 6 +++ tests/unit_tests/core/test_entity_helpers.py | 57 +++++++++++++++----- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index a3244856a2..5ad16ac76c 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -187,6 +187,12 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # No name to validate return config + # Skip validation for internal entities + # Internal entities are not exposed to Home Assistant and don't use the hash-based + # entity state tracking system, so name collisions don't matter for them + if config.get(CONF_INTERNAL, False): + return config + # Get the entity name entity_name = config[CONF_NAME] diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 0dcdd84507..4f256ffb33 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -8,9 +8,19 @@ from typing import Any import pytest from esphome.config_validation import Invalid -from esphome.const import CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ICON, CONF_NAME +from esphome.const import ( + CONF_DEVICE_ID, + CONF_DISABLED_BY_DEFAULT, + CONF_ICON, + CONF_INTERNAL, + CONF_NAME, +) from esphome.core import CORE, ID, entity_helpers -from esphome.core.entity_helpers import get_base_entity_object_id, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + get_base_entity_object_id, + setup_entity, +) from esphome.cpp_generator import MockObj from esphome.helpers import sanitize, snake_case @@ -493,11 +503,6 @@ async def test_setup_entity_disabled_by_default( def test_entity_duplicate_validator() -> None: """Test the entity_duplicate_validator function.""" - from esphome.core.entity_helpers import entity_duplicate_validator - - # Reset CORE unique_ids for clean test - CORE.unique_ids.clear() - # Create validator for sensor platform validator = entity_duplicate_validator("sensor") @@ -523,11 +528,6 @@ def test_entity_duplicate_validator() -> None: def test_entity_duplicate_validator_with_devices() -> None: """Test entity_duplicate_validator with devices.""" - from esphome.core.entity_helpers import entity_duplicate_validator - - # Reset CORE unique_ids for clean test - CORE.unique_ids.clear() - # Create validator for sensor platform validator = entity_duplicate_validator("sensor") @@ -605,3 +605,36 @@ def test_entity_different_platforms_yaml_validation( ) # This should succeed assert result is not None + + +def test_entity_duplicate_validator_internal_entities() -> None: + """Test that internal entities are excluded from duplicate name validation.""" + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # First entity should pass + config1 = {CONF_NAME: "Temperature"} + validated1 = validator(config1) + assert validated1 == config1 + assert ("sensor", "temperature") in CORE.unique_ids + + # Internal entity with same name should pass (not added to unique_ids) + config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} + validated2 = validator(config2) + assert validated2 == config2 + # Internal entity should not be added to unique_ids + assert len([k for k in CORE.unique_ids if k == ("sensor", "temperature")]) == 1 + + # Another internal entity with same name should also pass + config3 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} + validated3 = validator(config3) + assert validated3 == config3 + # Still only one entry in unique_ids (from the non-internal entity) + assert len([k for k in CORE.unique_ids if k == ("sensor", "temperature")]) == 1 + + # Non-internal entity with same name should fail + config4 = {CONF_NAME: "Temperature"} + with pytest.raises( + Invalid, match=r"Duplicate sensor entity with name 'Temperature' found" + ): + validator(config4) From 05238b447f6c8d76816b0f9a3b0614802a4e6f6e Mon Sep 17 00:00:00 2001 From: Adam Liddell Date: Thu, 10 Jul 2025 09:34:43 +0100 Subject: [PATCH 39/51] Handle ESP32 chunked MQTT messages missing topic on non-first chunks, causing panic (#5786) Co-authored-by: Samuel Sieb --- esphome/components/mqtt/mqtt_backend_esp32.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index a096408aa5..623206a0cd 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -153,11 +153,15 @@ void MQTTBackendESP32::mqtt_event_handler_(const Event &event) { case MQTT_EVENT_DATA: { static std::string topic; if (!event.topic.empty()) { + // When a single message arrives as multiple chunks, the topic will be empty + // on any but the first message, leading to event.topic being an empty string. + // To ensure handlers get the correct topic, cache the last seen topic to + // simulate always receiving the topic from underlying library topic = event.topic; } ESP_LOGV(TAG, "MQTT_EVENT_DATA %s", topic.c_str()); - this->on_message_.call(!event.topic.empty() ? topic.c_str() : nullptr, event.data.data(), event.data.size(), - event.current_data_offset, event.total_data_len); + this->on_message_.call(topic.c_str(), event.data.data(), event.data.size(), event.current_data_offset, + event.total_data_len); } break; case MQTT_EVENT_ERROR: ESP_LOGE(TAG, "MQTT_EVENT_ERROR"); From 143702beefa8a3b06141b858238bf7bd490510f0 Mon Sep 17 00:00:00 2001 From: DT-art1 <81360462+DT-art1@users.noreply.github.com> Date: Thu, 10 Jul 2025 10:35:24 +0200 Subject: [PATCH 40/51] Replace remaining instances of USE_ESP32_CAMERA with USE_CAMERA (#9401) --- esphome/components/api/api_connection.cpp | 2 +- esphome/components/esp32_camera/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 779784e787..537d75467f 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1920,7 +1920,7 @@ uint16_t APIConnection::get_estimated_message_size(uint16_t message_type) { case ListEntitiesClimateResponse::MESSAGE_TYPE: return ListEntitiesClimateResponse::ESTIMATED_SIZE; #endif -#ifdef USE_ESP32_CAMERA +#ifdef USE_CAMERA case ListEntitiesCameraResponse::MESSAGE_TYPE: return ListEntitiesCameraResponse::ESTIMATED_SIZE; #endif diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 138f318a5d..6e36f7d5a7 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -308,7 +308,7 @@ async def to_code(config): cg.add(var.set_frame_buffer_count(config[CONF_FRAME_BUFFER_COUNT])) cg.add(var.set_frame_size(config[CONF_RESOLUTION])) - cg.add_define("USE_ESP32_CAMERA") + cg.add_define("USE_CAMERA") if CORE.using_esp_idf: add_idf_component(name="espressif/esp32-camera", ref="2.0.15") From 8953e53a04a282fb3261e3be3da95aaebdee0c59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 10 Jul 2025 23:54:57 -1000 Subject: [PATCH 41/51] CI: Centralize test determination logic to reduce unnecessary job runners (#9432) --- .github/workflows/ci.yml | 105 +++++---- script/determine-jobs.py | 245 +++++++++++++++++++ script/helpers.py | 126 +++++++++- script/list-components.py | 27 ++- tests/script/test_determine_jobs.py | 352 ++++++++++++++++++++++++++++ tests/script/test_helpers.py | 165 +++++++++++++ 6 files changed, 963 insertions(+), 57 deletions(-) create mode 100755 script/determine-jobs.py create mode 100644 tests/script/test_determine_jobs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08d64243b9..503a50c5c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,8 @@ jobs: runs-on: ubuntu-24.04 needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 @@ -87,6 +89,8 @@ jobs: runs-on: ubuntu-24.04 needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 @@ -108,6 +112,8 @@ jobs: runs-on: ubuntu-24.04 needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 @@ -129,6 +135,8 @@ jobs: runs-on: ubuntu-24.04 needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 @@ -232,11 +240,54 @@ jobs: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} + determine-jobs: + name: Determine which jobs to run + runs-on: ubuntu-24.04 + needs: + - common + outputs: + integration-tests: ${{ steps.determine.outputs.integration-tests }} + clang-tidy: ${{ steps.determine.outputs.clang-tidy }} + clang-format: ${{ steps.determine.outputs.clang-format }} + python-linters: ${{ steps.determine.outputs.python-linters }} + changed-components: ${{ steps.determine.outputs.changed-components }} + component-test-count: ${{ steps.determine.outputs.component-test-count }} + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.2.2 + with: + # Fetch enough history to find the merge base + fetch-depth: 2 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Determine which tests to run + id: determine + env: + GH_TOKEN: ${{ github.token }} + run: | + . venv/bin/activate + output=$(python script/determine-jobs.py) + echo "Test determination output:" + echo "$output" | jq + + # Extract individual fields + echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT + echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT + echo "clang-format=$(echo "$output" | jq -r '.clang_format')" >> $GITHUB_OUTPUT + echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT + echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT + echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT + integration-tests: name: Run integration tests runs-on: ubuntu-latest needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.integration-tests == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 @@ -271,6 +322,8 @@ jobs: runs-on: ubuntu-24.04 needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.clang-format == 'true' steps: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 @@ -304,6 +357,8 @@ jobs: - pylint - pytest - pyupgrade + - determine-jobs + if: needs.determine-jobs.outputs.clang-tidy == 'true' env: GH_TOKEN: ${{ github.token }} strategy: @@ -411,50 +466,18 @@ jobs: # yamllint disable-line rule:line-length if: always() - list-components: - runs-on: ubuntu-24.04 - needs: - - common - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ github.token }} - outputs: - components: ${{ steps.list-components.outputs.components }} - count: ${{ steps.list-components.outputs.count }} - steps: - - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 - - name: Restore Python - uses: ./.github/actions/restore-python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Find changed components - id: list-components - run: | - . venv/bin/activate - components=$(script/list-components.py --changed) - output_components=$(echo "$components" | jq -R -s -c 'split("\n")[:-1] | map(select(length > 0))') - count=$(echo "$output_components" | jq length) - - echo "components=$output_components" >> $GITHUB_OUTPUT - echo "count=$count" >> $GITHUB_OUTPUT - - echo "$count Components:" - echo "$output_components" | jq - test-build-components: name: Component test ${{ matrix.file }} runs-on: ubuntu-24.04 needs: - common - - list-components - if: github.event_name == 'pull_request' && fromJSON(needs.list-components.outputs.count) > 0 && fromJSON(needs.list-components.outputs.count) < 100 + - determine-jobs + if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 && fromJSON(needs.determine-jobs.outputs.component-test-count) < 100 strategy: fail-fast: false max-parallel: 2 matrix: - file: ${{ fromJson(needs.list-components.outputs.components) }} + file: ${{ fromJson(needs.determine-jobs.outputs.changed-components) }} steps: - name: Install dependencies run: | @@ -482,8 +505,8 @@ jobs: runs-on: ubuntu-24.04 needs: - common - - list-components - if: github.event_name == 'pull_request' && fromJSON(needs.list-components.outputs.count) >= 100 + - determine-jobs + if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) >= 100 outputs: matrix: ${{ steps.split.outputs.components }} steps: @@ -492,7 +515,7 @@ jobs: - name: Split components into 20 groups id: split run: | - components=$(echo '${{ needs.list-components.outputs.components }}' | jq -c '.[]' | shuf | jq -s -c '[_nwise(20) | join(" ")]') + components=$(echo '${{ needs.determine-jobs.outputs.changed-components }}' | jq -c '.[]' | shuf | jq -s -c '[_nwise(20) | join(" ")]') echo "components=$components" >> $GITHUB_OUTPUT test-build-components-split: @@ -500,9 +523,9 @@ jobs: runs-on: ubuntu-24.04 needs: - common - - list-components + - determine-jobs - test-build-components-splitter - if: github.event_name == 'pull_request' && fromJSON(needs.list-components.outputs.count) >= 100 + if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) >= 100 strategy: fail-fast: false max-parallel: 4 @@ -553,7 +576,7 @@ jobs: - integration-tests - pyupgrade - clang-tidy - - list-components + - determine-jobs - test-build-components - test-build-components-splitter - test-build-components-split diff --git a/script/determine-jobs.py b/script/determine-jobs.py new file mode 100755 index 0000000000..fc5c397c65 --- /dev/null +++ b/script/determine-jobs.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Determine which CI jobs should run based on changed files. + +This script is a centralized way to determine which CI jobs need to run based on +what files have changed. It outputs JSON with the following structure: + +{ + "integration_tests": true/false, + "clang_tidy": true/false, + "clang_format": true/false, + "python_linters": true/false, + "changed_components": ["component1", "component2", ...], + "component_test_count": 5 +} + +The CI workflow uses this information to: +- Skip or run integration tests +- Skip or run clang-tidy (and whether to do a full scan) +- Skip or run clang-format +- Skip or run Python linters (ruff, flake8, pylint, pyupgrade) +- Determine which components to test individually +- Decide how to split component tests (if there are many) + +Usage: + python script/determine-jobs.py [-b BRANCH] + +Options: + -b, --branch BRANCH Branch to compare against (default: dev) +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any + +from helpers import ( + CPP_FILE_EXTENSIONS, + ESPHOME_COMPONENTS_PATH, + PYTHON_FILE_EXTENSIONS, + changed_files, + get_all_dependencies, + get_components_from_integration_fixtures, + parse_list_components_output, + root_path, +) + + +def should_run_integration_tests(branch: str | None = None) -> bool: + """Determine if integration tests should run based on changed files. + + This function is used by the CI workflow to intelligently skip integration tests when they're + not needed, saving significant CI time and resources. + + Integration tests will run when ANY of the following conditions are met: + + 1. Core C++ files changed (esphome/core/*) + - Any .cpp, .h, .tcc files in the core directory + - These files contain fundamental functionality used throughout ESPHome + - Examples: esphome/core/component.cpp, esphome/core/application.h + + 2. Core Python files changed (esphome/core/*.py) + - Only .py files in the esphome/core/ directory + - These are core Python files that affect the entire system + - Examples: esphome/core/config.py, esphome/core/__init__.py + - NOT included: esphome/*.py, esphome/dashboard/*.py, esphome/components/*/*.py + + 3. Integration test files changed + - Any file in tests/integration/ directory + - This includes test files themselves and fixture YAML files + - Examples: tests/integration/test_api.py, tests/integration/fixtures/api.yaml + + 4. Components used by integration tests (or their dependencies) changed + - The function parses all YAML files in tests/integration/fixtures/ + - Extracts which components are used in integration tests + - Recursively finds all dependencies of those components + - If any of these components have changes, tests must run + - Example: If api.yaml uses 'sensor' and 'api' components, and 'api' depends on 'socket', + then changes to sensor/, api/, or socket/ components trigger tests + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if integration tests should run, False otherwise. + """ + files = changed_files(branch) + + # Check if any core files changed (esphome/core/*) + for file in files: + if file.startswith("esphome/core/"): + return True + + # Check if any integration test files changed + if any("tests/integration" in file for file in files): + return True + + # Get all components used in integration tests and their dependencies + fixture_components = get_components_from_integration_fixtures() + all_required_components = get_all_dependencies(fixture_components) + + # Check if any required components changed + for file in files: + if file.startswith(ESPHOME_COMPONENTS_PATH): + parts = file.split("/") + if len(parts) >= 3: + component = parts[2] + if component in all_required_components: + return True + + return False + + +def should_run_clang_tidy(branch: str | None = None) -> bool: + """Determine if clang-tidy should run based on changed files. + + This function is used by the CI workflow to intelligently skip clang-tidy checks when they're + not needed, saving significant CI time and resources. + + Clang-tidy will run when ANY of the following conditions are met: + + 1. Clang-tidy configuration changed + - The hash of .clang-tidy configuration file has changed + - The hash includes the .clang-tidy file, clang-tidy version from requirements_dev.txt, + and relevant platformio.ini sections + - When configuration changes, a full scan is needed to ensure all code complies + with the new rules + - Detected by script/clang_tidy_hash.py --check returning exit code 0 + + 2. Any C++ source files changed + - Any file with C++ extensions: .cpp, .h, .hpp, .cc, .cxx, .c, .tcc + - Includes files anywhere in the repository, not just in esphome/ + - This ensures all C++ code is checked, including tests, examples, etc. + - Examples: esphome/core/component.cpp, tests/custom/my_component.h + + If the hash check fails for any reason, clang-tidy runs as a safety measure to ensure + code quality is maintained. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if clang-tidy should run, False otherwise. + """ + # First check if clang-tidy configuration changed (full scan needed) + try: + result = subprocess.run( + [os.path.join(root_path, "script", "clang_tidy_hash.py"), "--check"], + capture_output=True, + check=False, + ) + # Exit 0 means hash changed (full scan needed) + if result.returncode == 0: + return True + except Exception: + # If hash check fails, run clang-tidy to be safe + return True + + return _any_changed_file_endswith(branch, CPP_FILE_EXTENSIONS) + + +def should_run_clang_format(branch: str | None = None) -> bool: + """Determine if clang-format should run based on changed files. + + This function is used by the CI workflow to skip clang-format checks when no C++ files + have changed, saving CI time and resources. + + Clang-format will run when any C++ source files have changed. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if clang-format should run, False otherwise. + """ + return _any_changed_file_endswith(branch, CPP_FILE_EXTENSIONS) + + +def should_run_python_linters(branch: str | None = None) -> bool: + """Determine if Python linters (ruff, flake8, pylint, pyupgrade) should run based on changed files. + + This function is used by the CI workflow to skip Python linting checks when no Python files + have changed, saving CI time and resources. + + Python linters will run when any Python source files have changed. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if Python linters should run, False otherwise. + """ + return _any_changed_file_endswith(branch, PYTHON_FILE_EXTENSIONS) + + +def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool: + """Check if a changed file ends with any of the specified extensions.""" + return any(file.endswith(extensions) for file in changed_files(branch)) + + +def main() -> None: + """Main function that determines which CI jobs to run.""" + parser = argparse.ArgumentParser( + description="Determine which CI jobs should run based on changed files" + ) + parser.add_argument( + "-b", "--branch", help="Branch to compare changed files against" + ) + args = parser.parse_args() + + # Determine what should run + run_integration = should_run_integration_tests(args.branch) + run_clang_tidy = should_run_clang_tidy(args.branch) + run_clang_format = should_run_clang_format(args.branch) + run_python_linters = should_run_python_linters(args.branch) + + # Get changed components using list-components.py for exact compatibility + script_path = Path(__file__).parent / "list-components.py" + cmd = [sys.executable, str(script_path), "--changed"] + if args.branch: + cmd.extend(["-b", args.branch]) + + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + changed_components = parse_list_components_output(result.stdout) + + # Build output + output: dict[str, Any] = { + "integration_tests": run_integration, + "clang_tidy": run_clang_tidy, + "clang_format": run_clang_format, + "python_linters": run_python_linters, + "changed_components": changed_components, + "component_test_count": len(changed_components), + } + + # Output as JSON + print(json.dumps(output)) + + +if __name__ == "__main__": + main() diff --git a/script/helpers.py b/script/helpers.py index 5dbc7a32cc..ff63bbc5b6 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +from functools import cache import json import os import os.path @@ -7,6 +8,7 @@ from pathlib import Path import re import subprocess import time +from typing import Any import colorama @@ -15,6 +17,34 @@ basepath = os.path.join(root_path, "esphome") temp_folder = os.path.join(root_path, ".temp") temp_header_file = os.path.join(temp_folder, "all-include.cpp") +# C++ file extensions used for clang-tidy and clang-format checks +CPP_FILE_EXTENSIONS = (".cpp", ".h", ".hpp", ".cc", ".cxx", ".c", ".tcc") + +# Python file extensions +PYTHON_FILE_EXTENSIONS = (".py", ".pyi") + +# YAML file extensions +YAML_FILE_EXTENSIONS = (".yaml", ".yml") + +# Component path prefix +ESPHOME_COMPONENTS_PATH = "esphome/components/" + + +def parse_list_components_output(output: str) -> list[str]: + """Parse the output from list-components.py script. + + The script outputs one component name per line. + + Args: + output: The stdout from list-components.py + + Returns: + List of component names, or empty list if no output + """ + if not output or not output.strip(): + return [] + return [c.strip() for c in output.strip().split("\n") if c.strip()] + def styled(color: str | tuple[str, ...], msg: str, reset: bool = True) -> str: prefix = "".join(color) if isinstance(color, tuple) else color @@ -96,6 +126,7 @@ def _get_pr_number_from_github_env() -> str | None: return None +@cache def _get_changed_files_github_actions() -> list[str] | None: """Get changed files in GitHub Actions environment. @@ -135,7 +166,7 @@ def changed_files(branch: str | None = None) -> list[str]: return github_files # Original implementation for local development - if branch is None: + if not branch: # Treat None and empty string the same branch = "dev" check_remotes = ["upstream", "origin"] check_remotes.extend(splitlines_no_ends(get_output("git", "remote"))) @@ -183,7 +214,7 @@ def get_changed_components() -> list[str] | None: changed = changed_files() core_cpp_changed = any( f.startswith("esphome/core/") - and f.endswith((".cpp", ".h", ".hpp", ".cc", ".cxx", ".c")) + and f.endswith(CPP_FILE_EXTENSIONS[:-1]) # Exclude .tcc for core files for f in changed ) if core_cpp_changed: @@ -198,8 +229,7 @@ def get_changed_components() -> list[str] | None: result = subprocess.run( cmd, capture_output=True, text=True, check=True, close_fds=False ) - components = [c.strip() for c in result.stdout.strip().split("\n") if c.strip()] - return components + return parse_list_components_output(result.stdout) except subprocess.CalledProcessError: # If the script fails, fall back to full scan print("Could not determine changed components - will run full clang-tidy scan") @@ -249,7 +279,9 @@ def _filter_changed_ci(files: list[str]) -> list[str]: # Action: Check only the specific non-component files that changed changed = changed_files() files = [ - f for f in files if f in changed and not f.startswith("esphome/components/") + f + for f in files + if f in changed and not f.startswith(ESPHOME_COMPONENTS_PATH) ] if not files: print("No files changed") @@ -267,7 +299,7 @@ def _filter_changed_ci(files: list[str]) -> list[str]: # because changes in one file can affect other files in the same component. filtered_files = [] for f in files: - if f.startswith("esphome/components/"): + if f.startswith(ESPHOME_COMPONENTS_PATH): # Check if file belongs to any of the changed components parts = f.split("/") if len(parts) >= 3 and parts[2] in component_set: @@ -326,7 +358,7 @@ def git_ls_files(patterns: list[str] | None = None) -> dict[str, int]: return {s[3].strip(): int(s[0]) for s in lines} -def load_idedata(environment): +def load_idedata(environment: str) -> dict[str, Any]: start_time = time.time() print(f"Loading IDE data for environment '{environment}'...") @@ -442,3 +474,83 @@ def get_usable_cpu_count() -> int: return ( os.process_cpu_count() if hasattr(os, "process_cpu_count") else os.cpu_count() ) + + +def get_all_dependencies(component_names: set[str]) -> set[str]: + """Get all dependencies for a set of components. + + Args: + component_names: Set of component names to get dependencies for + + Returns: + Set of all components including dependencies and auto-loaded components + """ + from esphome.const import KEY_CORE + from esphome.core import CORE + from esphome.loader import get_component + + all_components: set[str] = set(component_names) + + # Reset CORE to ensure clean state + CORE.reset() + + # Set up fake config path for component loading + root = Path(__file__).parent.parent + CORE.config_path = str(root) + CORE.data[KEY_CORE] = {} + + # Keep finding dependencies until no new ones are found + while True: + new_components: set[str] = set() + + for comp_name in all_components: + comp = get_component(comp_name) + if not comp: + continue + + # Add dependencies (extract component name before '.') + new_components.update(dep.split(".")[0] for dep in comp.dependencies) + + # Add auto_load components + new_components.update(comp.auto_load) + + # Check if we found any new components + new_components -= all_components + if not new_components: + break + + all_components.update(new_components) + + return all_components + + +def get_components_from_integration_fixtures() -> set[str]: + """Extract all components used in integration test fixtures. + + Returns: + Set of component names used in integration test fixtures + """ + import yaml + + components: set[str] = set() + fixtures_dir = Path(__file__).parent.parent / "tests" / "integration" / "fixtures" + + for yaml_file in fixtures_dir.glob("*.yaml"): + with open(yaml_file) as f: + config: dict[str, any] | None = yaml.safe_load(f) + if not config: + continue + + # Add all top-level component keys + components.update(config.keys()) + + # Add platform components (e.g., output.template) + for value in config.values(): + if not isinstance(value, list): + continue + + for item in value: + if isinstance(item, dict) and "platform" in item: + components.add(item["platform"]) + + return components diff --git a/script/list-components.py b/script/list-components.py index 0afcaa0f9d..66212f44e7 100755 --- a/script/list-components.py +++ b/script/list-components.py @@ -20,6 +20,12 @@ def filter_component_files(str): return str.startswith("esphome/components/") | str.startswith("tests/components/") +def get_all_component_files() -> list[str]: + """Get all component files from git.""" + files = git_ls_files() + return list(filter(filter_component_files, files)) + + def extract_component_names_array_from_files_array(files): components = [] for file in files: @@ -165,17 +171,20 @@ def main(): if args.branch and not args.changed: parser.error("--branch requires --changed") - files = git_ls_files() - files = filter(filter_component_files, files) - if args.changed: - if args.branch: - changed = changed_files(args.branch) - else: - changed = changed_files() + # When --changed is passed, only get the changed files + changed = changed_files(args.branch) + # If any base test file(s) changed, there's no need to filter out components - if not any("tests/test_build_components" in file for file in changed): - files = [f for f in files if f in changed] + if any("tests/test_build_components" in file for file in changed): + # Need to get all component files + files = get_all_component_files() + else: + # Only look at changed component files + files = [f for f in changed if filter_component_files(f)] + else: + # Get all component files + files = get_all_component_files() for c in get_components(files, args.changed): print(c) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py new file mode 100644 index 0000000000..4aaaadd80a --- /dev/null +++ b/tests/script/test_determine_jobs.py @@ -0,0 +1,352 @@ +"""Unit tests for script/determine-jobs.py module.""" + +from collections.abc import Generator +import importlib.util +import json +import os +import subprocess +import sys +from unittest.mock import Mock, patch + +import pytest + +# Add the script directory to Python path so we can import the module +script_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "script") +) +sys.path.insert(0, script_dir) + +spec = importlib.util.spec_from_file_location( + "determine_jobs", os.path.join(script_dir, "determine-jobs.py") +) +determine_jobs = importlib.util.module_from_spec(spec) +spec.loader.exec_module(determine_jobs) + + +@pytest.fixture +def mock_should_run_integration_tests() -> Generator[Mock, None, None]: + """Mock should_run_integration_tests from helpers.""" + with patch.object(determine_jobs, "should_run_integration_tests") as mock: + yield mock + + +@pytest.fixture +def mock_should_run_clang_tidy() -> Generator[Mock, None, None]: + """Mock should_run_clang_tidy from helpers.""" + with patch.object(determine_jobs, "should_run_clang_tidy") as mock: + yield mock + + +@pytest.fixture +def mock_should_run_clang_format() -> Generator[Mock, None, None]: + """Mock should_run_clang_format from helpers.""" + with patch.object(determine_jobs, "should_run_clang_format") as mock: + yield mock + + +@pytest.fixture +def mock_should_run_python_linters() -> Generator[Mock, None, None]: + """Mock should_run_python_linters from helpers.""" + with patch.object(determine_jobs, "should_run_python_linters") as mock: + yield mock + + +@pytest.fixture +def mock_subprocess_run() -> Generator[Mock, None, None]: + """Mock subprocess.run for list-components.py calls.""" + with patch.object(determine_jobs.subprocess, "run") as mock: + yield mock + + +def test_main_all_tests_should_run( + mock_should_run_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_subprocess_run: Mock, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test when all tests should run.""" + mock_should_run_integration_tests.return_value = True + mock_should_run_clang_tidy.return_value = True + mock_should_run_clang_format.return_value = True + mock_should_run_python_linters.return_value = True + + # Mock list-components.py output + mock_result = Mock() + mock_result.stdout = "wifi\napi\nsensor\n" + mock_subprocess_run.return_value = mock_result + + # Run main function with mocked argv + with patch("sys.argv", ["determine-jobs.py"]): + determine_jobs.main() + + # Check output + captured = capsys.readouterr() + output = json.loads(captured.out) + + assert output["integration_tests"] is True + assert output["clang_tidy"] is True + assert output["clang_format"] is True + assert output["python_linters"] is True + assert output["changed_components"] == ["wifi", "api", "sensor"] + assert output["component_test_count"] == 3 + + +def test_main_no_tests_should_run( + mock_should_run_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_subprocess_run: Mock, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test when no tests should run.""" + mock_should_run_integration_tests.return_value = False + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + + # Mock empty list-components.py output + mock_result = Mock() + mock_result.stdout = "" + mock_subprocess_run.return_value = mock_result + + # Run main function with mocked argv + with patch("sys.argv", ["determine-jobs.py"]): + determine_jobs.main() + + # Check output + captured = capsys.readouterr() + output = json.loads(captured.out) + + assert output["integration_tests"] is False + assert output["clang_tidy"] is False + assert output["clang_format"] is False + assert output["python_linters"] is False + assert output["changed_components"] == [] + assert output["component_test_count"] == 0 + + +def test_main_list_components_fails( + mock_should_run_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_subprocess_run: Mock, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test when list-components.py fails.""" + mock_should_run_integration_tests.return_value = True + mock_should_run_clang_tidy.return_value = True + mock_should_run_clang_format.return_value = True + mock_should_run_python_linters.return_value = True + + # Mock list-components.py failure + mock_subprocess_run.side_effect = subprocess.CalledProcessError(1, "cmd") + + # Run main function with mocked argv - should raise + with patch("sys.argv", ["determine-jobs.py"]): + with pytest.raises(subprocess.CalledProcessError): + determine_jobs.main() + + +def test_main_with_branch_argument( + mock_should_run_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_subprocess_run: Mock, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test with branch argument.""" + mock_should_run_integration_tests.return_value = False + mock_should_run_clang_tidy.return_value = True + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = True + + # Mock list-components.py output + mock_result = Mock() + mock_result.stdout = "mqtt\n" + mock_subprocess_run.return_value = mock_result + + with patch("sys.argv", ["script.py", "-b", "main"]): + determine_jobs.main() + + # Check that functions were called with branch + mock_should_run_integration_tests.assert_called_once_with("main") + mock_should_run_clang_tidy.assert_called_once_with("main") + mock_should_run_clang_format.assert_called_once_with("main") + mock_should_run_python_linters.assert_called_once_with("main") + + # Check that list-components.py was called with branch + mock_subprocess_run.assert_called_once() + call_args = mock_subprocess_run.call_args[0][0] + assert "--changed" in call_args + assert "-b" in call_args + assert "main" in call_args + + # Check output + captured = capsys.readouterr() + output = json.loads(captured.out) + + assert output["integration_tests"] is False + assert output["clang_tidy"] is True + assert output["clang_format"] is False + assert output["python_linters"] is True + assert output["changed_components"] == ["mqtt"] + assert output["component_test_count"] == 1 + + +def test_should_run_integration_tests( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test should_run_integration_tests function.""" + # Core C++ files trigger tests + with patch.object( + determine_jobs, "changed_files", return_value=["esphome/core/component.cpp"] + ): + result = determine_jobs.should_run_integration_tests() + assert result is True + + # Core Python files trigger tests + with patch.object( + determine_jobs, "changed_files", return_value=["esphome/core/config.py"] + ): + result = determine_jobs.should_run_integration_tests() + assert result is True + + # Python files directly in esphome/ do NOT trigger tests + with patch.object( + determine_jobs, "changed_files", return_value=["esphome/config.py"] + ): + result = determine_jobs.should_run_integration_tests() + assert result is False + + # Python files in subdirectories (not core) do NOT trigger tests + with patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/dashboard/web_server.py"], + ): + result = determine_jobs.should_run_integration_tests() + assert result is False + + +def test_should_run_integration_tests_with_branch() -> None: + """Test should_run_integration_tests with branch argument.""" + with patch.object(determine_jobs, "changed_files") as mock_changed: + mock_changed.return_value = [] + determine_jobs.should_run_integration_tests("release") + mock_changed.assert_called_once_with("release") + + +def test_should_run_integration_tests_component_dependency() -> None: + """Test that integration tests run when components used in fixtures change.""" + with patch.object( + determine_jobs, "changed_files", return_value=["esphome/components/api/api.cpp"] + ): + with patch.object( + determine_jobs, "get_components_from_integration_fixtures" + ) as mock_fixtures: + mock_fixtures.return_value = {"api", "sensor"} + with patch.object(determine_jobs, "get_all_dependencies") as mock_deps: + mock_deps.return_value = {"api", "sensor", "network"} + result = determine_jobs.should_run_integration_tests() + assert result is True + + +@pytest.mark.parametrize( + ("check_returncode", "changed_files", "expected_result"), + [ + (0, [], True), # Hash changed - need full scan + (1, ["esphome/core.cpp"], True), # C++ file changed + (1, ["README.md"], False), # No C++ files changed + ], +) +def test_should_run_clang_tidy( + check_returncode: int, + changed_files: list[str], + expected_result: bool, +) -> None: + """Test should_run_clang_tidy function.""" + with patch.object(determine_jobs, "changed_files", return_value=changed_files): + # Test with hash check returning specific code + with patch("subprocess.run") as mock_run: + mock_run.return_value = Mock(returncode=check_returncode) + result = determine_jobs.should_run_clang_tidy() + assert result == expected_result + + # Test with hash check failing (exception) + if check_returncode != 0: + with patch("subprocess.run", side_effect=Exception("Failed")): + result = determine_jobs.should_run_clang_tidy() + assert result is True # Fail safe - run clang-tidy + + +def test_should_run_clang_tidy_with_branch() -> None: + """Test should_run_clang_tidy with branch argument.""" + with patch.object(determine_jobs, "changed_files") as mock_changed: + mock_changed.return_value = [] + with patch("subprocess.run") as mock_run: + mock_run.return_value = Mock(returncode=1) # Hash unchanged + determine_jobs.should_run_clang_tidy("release") + mock_changed.assert_called_once_with("release") + + +@pytest.mark.parametrize( + ("changed_files", "expected_result"), + [ + (["esphome/core.py"], True), + (["script/test.py"], True), + (["esphome/test.pyi"], True), # .pyi files should trigger + (["README.md"], False), + ([], False), + ], +) +def test_should_run_python_linters( + changed_files: list[str], expected_result: bool +) -> None: + """Test should_run_python_linters function.""" + with patch.object(determine_jobs, "changed_files", return_value=changed_files): + result = determine_jobs.should_run_python_linters() + assert result == expected_result + + +def test_should_run_python_linters_with_branch() -> None: + """Test should_run_python_linters with branch argument.""" + with patch.object(determine_jobs, "changed_files") as mock_changed: + mock_changed.return_value = [] + determine_jobs.should_run_python_linters("release") + mock_changed.assert_called_once_with("release") + + +@pytest.mark.parametrize( + ("changed_files", "expected_result"), + [ + (["esphome/core.cpp"], True), + (["esphome/core.h"], True), + (["test.hpp"], True), + (["test.cc"], True), + (["test.cxx"], True), + (["test.c"], True), + (["test.tcc"], True), + (["README.md"], False), + ([], False), + ], +) +def test_should_run_clang_format( + changed_files: list[str], expected_result: bool +) -> None: + """Test should_run_clang_format function.""" + with patch.object(determine_jobs, "changed_files", return_value=changed_files): + result = determine_jobs.should_run_clang_format() + assert result == expected_result + + +def test_should_run_clang_format_with_branch() -> None: + """Test should_run_clang_format with branch argument.""" + with patch.object(determine_jobs, "changed_files") as mock_changed: + mock_changed.return_value = [] + determine_jobs.should_run_clang_format("release") + mock_changed.assert_called_once_with("release") diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index bbebdd79c8..d0db08e6f7 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -27,6 +27,7 @@ _filter_changed_ci = helpers._filter_changed_ci _filter_changed_local = helpers._filter_changed_local build_all_include = helpers.build_all_include print_file_list = helpers.print_file_list +get_all_dependencies = helpers.get_all_dependencies @pytest.mark.parametrize( @@ -154,6 +155,14 @@ def test_github_actions_push_event(monkeypatch: MonkeyPatch) -> None: assert result == expected_files +@pytest.fixture(autouse=True) +def clear_caches(): + """Clear function caches before each test.""" + # Clear the cache for _get_changed_files_github_actions + _get_changed_files_github_actions.cache_clear() + yield + + def test_get_changed_files_github_actions_pull_request( monkeypatch: MonkeyPatch, ) -> None: @@ -847,3 +856,159 @@ def test_print_file_list_default_title(capsys: pytest.CaptureFixture[str]) -> No assert "Files:" in captured.out assert " test.cpp" in captured.out + + +@pytest.mark.parametrize( + ("component_configs", "initial_components", "expected_components"), + [ + # No dependencies + ( + {"sensor": ([], [])}, # (dependencies, auto_load) + {"sensor"}, + {"sensor"}, + ), + # Simple dependencies + ( + { + "sensor": (["esp32"], []), + "esp32": ([], []), + }, + {"sensor"}, + {"sensor", "esp32"}, + ), + # Auto-load components + ( + { + "light": ([], ["output", "power_supply"]), + "output": ([], []), + "power_supply": ([], []), + }, + {"light"}, + {"light", "output", "power_supply"}, + ), + # Transitive dependencies + ( + { + "comp_a": (["comp_b"], []), + "comp_b": (["comp_c"], []), + "comp_c": ([], []), + }, + {"comp_a"}, + {"comp_a", "comp_b", "comp_c"}, + ), + # Dependencies with dots (sensor.base) + ( + { + "my_comp": (["sensor.base", "binary_sensor.base"], []), + "sensor": ([], []), + "binary_sensor": ([], []), + }, + {"my_comp"}, + {"my_comp", "sensor", "binary_sensor"}, + ), + # Circular dependencies (should not cause infinite loop) + ( + { + "comp_a": (["comp_b"], []), + "comp_b": (["comp_a"], []), + }, + {"comp_a"}, + {"comp_a", "comp_b"}, + ), + ], +) +def test_get_all_dependencies( + component_configs: dict[str, tuple[list[str], list[str]]], + initial_components: set[str], + expected_components: set[str], +) -> None: + """Test dependency resolution for components.""" + with patch("esphome.loader.get_component") as mock_get_component: + + def get_component_side_effect(name: str): + if name in component_configs: + deps, auto_load = component_configs[name] + comp = Mock() + comp.dependencies = deps + comp.auto_load = auto_load + return comp + return None + + mock_get_component.side_effect = get_component_side_effect + + result = helpers.get_all_dependencies(initial_components) + + assert result == expected_components + + +def test_get_all_dependencies_handles_missing_components() -> None: + """Test handling of components that can't be loaded.""" + with patch("esphome.loader.get_component") as mock_get_component: + # First component exists, its dependency doesn't + comp = Mock() + comp.dependencies = ["missing_comp"] + comp.auto_load = [] + + mock_get_component.side_effect = ( + lambda name: comp if name == "existing" else None + ) + + result = helpers.get_all_dependencies({"existing", "nonexistent"}) + + # Should still include all components, even if some can't be loaded + assert result == {"existing", "nonexistent", "missing_comp"} + + +def test_get_all_dependencies_empty_set() -> None: + """Test with empty initial component set.""" + result = helpers.get_all_dependencies(set()) + assert result == set() + + +def test_get_components_from_integration_fixtures() -> None: + """Test extraction of components from fixture YAML files.""" + yaml_content = { + "sensor": [{"platform": "template", "name": "test"}], + "binary_sensor": [{"platform": "gpio", "pin": 5}], + "esphome": {"name": "test"}, + "api": {}, + } + expected_components = { + "sensor", + "binary_sensor", + "esphome", + "api", + "template", + "gpio", + } + + mock_yaml_file = Mock() + + with ( + patch("pathlib.Path.glob") as mock_glob, + patch("builtins.open", create=True), + patch("yaml.safe_load", return_value=yaml_content), + ): + mock_glob.return_value = [mock_yaml_file] + + components = helpers.get_components_from_integration_fixtures() + + assert components == expected_components + + +@pytest.mark.parametrize( + "output,expected", + [ + ("wifi\napi\nsensor\n", ["wifi", "api", "sensor"]), + ("wifi\n", ["wifi"]), + ("", []), + (" \n \n", []), + ("\n\n", []), + (" wifi \n api \n", ["wifi", "api"]), + ("wifi\n\napi\n\nsensor", ["wifi", "api", "sensor"]), + ], +) +def test_parse_list_components_output(output: str, expected: list[str]) -> None: + """Test parse_list_components_output function.""" + result = helpers.parse_list_components_output(output) + assert result == expected From 475fe60f270d0978bd13f7e9f9a41f9066d3926c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jul 2025 08:33:18 -1000 Subject: [PATCH 42/51] Sync api.proto from aioesphomeapi (#9393) --- esphome/components/api/api.proto | 38 ++++++ esphome/components/api/api_pb2.cpp | 148 +++++++++++++++++++++++- esphome/components/api/api_pb2.h | 109 +++++++++-------- esphome/components/api/api_pb2_dump.cpp | 95 +++++++++++++++ 4 files changed, 332 insertions(+), 58 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c3795bb796..c35e603628 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -374,6 +374,7 @@ message CoverCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_COVER"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; @@ -387,6 +388,7 @@ message CoverCommandRequest { bool has_tilt = 6; float tilt = 7; bool stop = 8; + uint32 device_id = 9; } // ==================== FAN ==================== @@ -441,6 +443,7 @@ message FanCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_FAN"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; bool has_state = 2; @@ -455,6 +458,7 @@ message FanCommandRequest { int32 speed_level = 11; bool has_preset_mode = 12; string preset_mode = 13; + uint32 device_id = 14; } // ==================== LIGHT ==================== @@ -523,6 +527,7 @@ message LightCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_LIGHT"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; bool has_state = 2; @@ -551,6 +556,7 @@ message LightCommandRequest { uint32 flash_length = 17; bool has_effect = 18; string effect = 19; + uint32 device_id = 28; } // ==================== SENSOR ==================== @@ -640,9 +646,11 @@ message SwitchCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_SWITCH"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; bool state = 2; + uint32 device_id = 3; } // ==================== TEXT SENSOR ==================== @@ -850,12 +858,14 @@ message ListEntitiesCameraResponse { message CameraImageResponse { option (id) = 44; + option (base_class) = "StateResponseProtoMessage"; option (source) = SOURCE_SERVER; option (ifdef) = "USE_CAMERA"; fixed32 key = 1; bytes data = 2; bool done = 3; + uint32 device_id = 4; } message CameraImageRequest { option (id) = 45; @@ -980,6 +990,7 @@ message ClimateCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_CLIMATE"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; bool has_mode = 2; @@ -1005,6 +1016,7 @@ message ClimateCommandRequest { string custom_preset = 21; bool has_target_humidity = 22; float target_humidity = 23; + uint32 device_id = 24; } // ==================== NUMBER ==================== @@ -1054,9 +1066,11 @@ message NumberCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_NUMBER"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; float state = 2; + uint32 device_id = 3; } // ==================== SELECT ==================== @@ -1096,9 +1110,11 @@ message SelectCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_SELECT"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; string state = 2; + uint32 device_id = 3; } // ==================== SIREN ==================== @@ -1137,6 +1153,7 @@ message SirenCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_SIREN"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; bool has_state = 2; @@ -1147,6 +1164,7 @@ message SirenCommandRequest { uint32 duration = 7; bool has_volume = 8; float volume = 9; + uint32 device_id = 10; } // ==================== LOCK ==================== @@ -1201,12 +1219,14 @@ message LockCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_LOCK"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; LockCommand command = 2; // Not yet implemented: bool has_code = 3; string code = 4; + uint32 device_id = 5; } // ==================== BUTTON ==================== @@ -1232,8 +1252,10 @@ message ButtonCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_BUTTON"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; + uint32 device_id = 2; } // ==================== MEDIA PLAYER ==================== @@ -1301,6 +1323,7 @@ message MediaPlayerCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_MEDIA_PLAYER"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; @@ -1315,6 +1338,7 @@ message MediaPlayerCommandRequest { bool has_announcement = 8; bool announcement = 9; + uint32 device_id = 10; } // ==================== BLUETOOTH ==================== @@ -1843,9 +1867,11 @@ message AlarmControlPanelCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_ALARM_CONTROL_PANEL"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; AlarmControlPanelStateCommand command = 2; string code = 3; + uint32 device_id = 4; } // ===================== TEXT ===================== @@ -1892,9 +1918,11 @@ message TextCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_TEXT"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; string state = 2; + uint32 device_id = 3; } @@ -1936,11 +1964,13 @@ message DateCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_DATETIME_DATE"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; uint32 year = 2; uint32 month = 3; uint32 day = 4; + uint32 device_id = 5; } // ==================== DATETIME TIME ==================== @@ -1981,11 +2011,13 @@ message TimeCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_DATETIME_TIME"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; uint32 hour = 2; uint32 minute = 3; uint32 second = 4; + uint32 device_id = 5; } // ==================== EVENT ==================== @@ -2065,11 +2097,13 @@ message ValveCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_VALVE"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; bool has_position = 2; float position = 3; bool stop = 4; + uint32 device_id = 5; } // ==================== DATETIME DATETIME ==================== @@ -2108,9 +2142,11 @@ message DateTimeCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_DATETIME_DATETIME"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; fixed32 epoch_seconds = 2; + uint32 device_id = 3; } // ==================== UPDATE ==================== @@ -2160,7 +2196,9 @@ message UpdateCommandRequest { option (source) = SOURCE_CLIENT; option (ifdef) = "USE_UPDATE"; option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; fixed32 key = 1; UpdateCommand command = 2; + uint32 device_id = 3; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 3505ec758d..af82299f53 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -623,6 +623,10 @@ bool CoverCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { this->stop = value.as_bool(); return true; } + case 9: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -654,6 +658,7 @@ void CoverCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(6, this->has_tilt); buffer.encode_float(7, this->tilt); buffer.encode_bool(8, this->stop); + buffer.encode_uint32(9, this->device_id); } void CoverCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); @@ -664,6 +669,7 @@ void CoverCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->has_tilt, false); ProtoSize::add_fixed_field<4>(total_size, 1, this->tilt != 0.0f, false); ProtoSize::add_bool_field(total_size, 1, this->stop, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_FAN @@ -889,6 +895,10 @@ bool FanCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { this->has_preset_mode = value.as_bool(); return true; } + case 14: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -927,6 +937,7 @@ void FanCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_int32(11, this->speed_level); buffer.encode_bool(12, this->has_preset_mode); buffer.encode_string(13, this->preset_mode); + buffer.encode_uint32(14, this->device_id); } void FanCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); @@ -942,6 +953,7 @@ void FanCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_int32_field(total_size, 1, this->speed_level, false); ProtoSize::add_bool_field(total_size, 1, this->has_preset_mode, false); ProtoSize::add_string_field(total_size, 1, this->preset_mode, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_LIGHT @@ -1247,6 +1259,10 @@ bool LightCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { this->has_effect = value.as_bool(); return true; } + case 28: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -1335,6 +1351,7 @@ void LightCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(17, this->flash_length); buffer.encode_bool(18, this->has_effect); buffer.encode_string(19, this->effect); + buffer.encode_uint32(28, this->device_id); } void LightCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); @@ -1364,6 +1381,7 @@ void LightCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_uint32_field(total_size, 2, this->flash_length, false); ProtoSize::add_bool_field(total_size, 2, this->has_effect, false); ProtoSize::add_string_field(total_size, 2, this->effect, false); + ProtoSize::add_uint32_field(total_size, 2, this->device_id, false); } #endif #ifdef USE_SENSOR @@ -1637,6 +1655,10 @@ bool SwitchCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { this->state = value.as_bool(); return true; } + case 3: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -1654,10 +1676,12 @@ bool SwitchCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { void SwitchCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->key); buffer.encode_bool(2, this->state); + buffer.encode_uint32(3, this->device_id); } void SwitchCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); ProtoSize::add_bool_field(total_size, 1, this->state, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_TEXT_SENSOR @@ -2293,6 +2317,10 @@ bool CameraImageResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { this->done = value.as_bool(); return true; } + case 4: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -2321,11 +2349,13 @@ void CameraImageResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->key); buffer.encode_bytes(2, reinterpret_cast(this->data.data()), this->data.size()); buffer.encode_bool(3, this->done); + buffer.encode_uint32(4, this->device_id); } void CameraImageResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); ProtoSize::add_string_field(total_size, 1, this->data, false); ProtoSize::add_bool_field(total_size, 1, this->done, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } bool CameraImageRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2749,6 +2779,10 @@ bool ClimateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) this->has_target_humidity = value.as_bool(); return true; } + case 24: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -2817,6 +2851,7 @@ void ClimateCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(21, this->custom_preset); buffer.encode_bool(22, this->has_target_humidity); buffer.encode_float(23, this->target_humidity); + buffer.encode_uint32(24, this->device_id); } void ClimateCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); @@ -2842,6 +2877,7 @@ void ClimateCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 2, this->custom_preset, false); ProtoSize::add_bool_field(total_size, 2, this->has_target_humidity, false); ProtoSize::add_fixed_field<4>(total_size, 2, this->target_humidity != 0.0f, false); + ProtoSize::add_uint32_field(total_size, 2, this->device_id, false); } #endif #ifdef USE_NUMBER @@ -2991,6 +3027,16 @@ void NumberStateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->missing_state, false); ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } +bool NumberCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 3: { + this->device_id = value.as_uint32(); + return true; + } + default: + return false; + } +} bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { case 1: { @@ -3008,10 +3054,12 @@ bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { void NumberCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->key); buffer.encode_float(2, this->state); + buffer.encode_uint32(3, this->device_id); } void NumberCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); ProtoSize::add_fixed_field<4>(total_size, 1, this->state != 0.0f, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_SELECT @@ -3143,6 +3191,16 @@ void SelectStateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->missing_state, false); ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } +bool SelectCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 3: { + this->device_id = value.as_uint32(); + return true; + } + default: + return false; + } +} bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { @@ -3166,10 +3224,12 @@ bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { void SelectCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->key); buffer.encode_string(2, this->state); + buffer.encode_uint32(3, this->device_id); } void SelectCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); ProtoSize::add_string_field(total_size, 1, this->state, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_SIREN @@ -3327,6 +3387,10 @@ bool SirenCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { this->has_volume = value.as_bool(); return true; } + case 10: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -3365,6 +3429,7 @@ void SirenCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(7, this->duration); buffer.encode_bool(8, this->has_volume); buffer.encode_float(9, this->volume); + buffer.encode_uint32(10, this->device_id); } void SirenCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); @@ -3376,6 +3441,7 @@ void SirenCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_uint32_field(total_size, 1, this->duration, false); ProtoSize::add_bool_field(total_size, 1, this->has_volume, false); ProtoSize::add_fixed_field<4>(total_size, 1, this->volume != 0.0f, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_LOCK @@ -3517,6 +3583,10 @@ bool LockCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { this->has_code = value.as_bool(); return true; } + case 5: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -3546,12 +3616,14 @@ void LockCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_enum(2, this->command); buffer.encode_bool(3, this->has_code); buffer.encode_string(4, this->code); + buffer.encode_uint32(5, this->device_id); } void LockCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->command), false); ProtoSize::add_bool_field(total_size, 1, this->has_code, false); ProtoSize::add_string_field(total_size, 1, this->code, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_BUTTON @@ -3631,6 +3703,16 @@ void ListEntitiesButtonResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->device_class, false); ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } +bool ButtonCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 2: { + this->device_id = value.as_uint32(); + return true; + } + default: + return false; + } +} bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { case 1: { @@ -3641,9 +3723,13 @@ bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { return false; } } -void ButtonCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->key); } +void ButtonCommandRequest::encode(ProtoWriteBuffer buffer) const { + buffer.encode_fixed32(1, this->key); + buffer.encode_uint32(2, this->device_id); +} void ButtonCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_MEDIA_PLAYER @@ -3849,6 +3935,10 @@ bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt val this->announcement = value.as_bool(); return true; } + case 10: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -3887,6 +3977,7 @@ void MediaPlayerCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(7, this->media_url); buffer.encode_bool(8, this->has_announcement); buffer.encode_bool(9, this->announcement); + buffer.encode_uint32(10, this->device_id); } void MediaPlayerCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); @@ -3898,6 +3989,7 @@ void MediaPlayerCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->media_url, false); ProtoSize::add_bool_field(total_size, 1, this->has_announcement, false); ProtoSize::add_bool_field(total_size, 1, this->announcement, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_BLUETOOTH_PROXY @@ -5311,6 +5403,10 @@ bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, ProtoVarI this->command = value.as_enum(); return true; } + case 4: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -5339,11 +5435,13 @@ void AlarmControlPanelCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->key); buffer.encode_enum(2, this->command); buffer.encode_string(3, this->code); + buffer.encode_uint32(4, this->device_id); } void AlarmControlPanelCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->command), false); ProtoSize::add_string_field(total_size, 1, this->code, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_TEXT @@ -5487,6 +5585,16 @@ void TextStateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->missing_state, false); ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } +bool TextCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 3: { + this->device_id = value.as_uint32(); + return true; + } + default: + return false; + } +} bool TextCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { @@ -5510,10 +5618,12 @@ bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { void TextCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->key); buffer.encode_string(2, this->state); + buffer.encode_uint32(3, this->device_id); } void TextCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); ProtoSize::add_string_field(total_size, 1, this->state, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_DATETIME_DATE @@ -5653,6 +5763,10 @@ bool DateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { this->day = value.as_uint32(); return true; } + case 5: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -5672,12 +5786,14 @@ void DateCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->year); buffer.encode_uint32(3, this->month); buffer.encode_uint32(4, this->day); + buffer.encode_uint32(5, this->device_id); } void DateCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); ProtoSize::add_uint32_field(total_size, 1, this->year, false); ProtoSize::add_uint32_field(total_size, 1, this->month, false); ProtoSize::add_uint32_field(total_size, 1, this->day, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_DATETIME_TIME @@ -5817,6 +5933,10 @@ bool TimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { this->second = value.as_uint32(); return true; } + case 5: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -5836,12 +5956,14 @@ void TimeCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->hour); buffer.encode_uint32(3, this->minute); buffer.encode_uint32(4, this->second); + buffer.encode_uint32(5, this->device_id); } void TimeCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); ProtoSize::add_uint32_field(total_size, 1, this->hour, false); ProtoSize::add_uint32_field(total_size, 1, this->minute, false); ProtoSize::add_uint32_field(total_size, 1, this->second, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_EVENT @@ -6119,6 +6241,10 @@ bool ValveCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { this->stop = value.as_bool(); return true; } + case 5: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -6142,12 +6268,14 @@ void ValveCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(2, this->has_position); buffer.encode_float(3, this->position); buffer.encode_bool(4, this->stop); + buffer.encode_uint32(5, this->device_id); } void ValveCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); ProtoSize::add_bool_field(total_size, 1, this->has_position, false); ProtoSize::add_fixed_field<4>(total_size, 1, this->position != 0.0f, false); ProtoSize::add_bool_field(total_size, 1, this->stop, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_DATETIME_DATETIME @@ -6261,6 +6389,16 @@ void DateTimeStateResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->epoch_seconds != 0, false); ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } +bool DateTimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 3: { + this->device_id = value.as_uint32(); + return true; + } + default: + return false; + } +} bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { case 1: { @@ -6278,10 +6416,12 @@ bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { void DateTimeCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->key); buffer.encode_fixed32(2, this->epoch_seconds); + buffer.encode_uint32(3, this->device_id); } void DateTimeCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); ProtoSize::add_fixed_field<4>(total_size, 1, this->epoch_seconds != 0, false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif #ifdef USE_UPDATE @@ -6455,6 +6595,10 @@ bool UpdateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { this->command = value.as_enum(); return true; } + case 3: { + this->device_id = value.as_uint32(); + return true; + } default: return false; } @@ -6472,10 +6616,12 @@ bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { void UpdateCommandRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->key); buffer.encode_enum(2, this->command); + buffer.encode_uint32(3, this->device_id); } void UpdateCommandRequest::calculate_size(uint32_t &total_size) const { ProtoSize::add_fixed_field<4>(total_size, 1, this->key != 0, false); ProtoSize::add_enum_field(total_size, 1, static_cast(this->command), false); + ProtoSize::add_uint32_field(total_size, 1, this->device_id, false); } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 3bfc5f1cf4..029f22dfc2 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -307,6 +307,15 @@ class StateResponseProtoMessage : public ProtoMessage { protected: }; + +class CommandProtoMessage : public ProtoMessage { + public: + ~CommandProtoMessage() override = default; + uint32_t key{0}; + uint32_t device_id{0}; + + protected: +}; class HelloRequest : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 1; @@ -640,14 +649,13 @@ class CoverStateResponse : public StateResponseProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class CoverCommandRequest : public ProtoMessage { +class CoverCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 30; - static constexpr uint16_t ESTIMATED_SIZE = 25; + static constexpr uint16_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "cover_command_request"; } #endif - uint32_t key{0}; bool has_legacy_command{false}; enums::LegacyCoverCommand legacy_command{}; bool has_position{false}; @@ -714,14 +722,13 @@ class FanStateResponse : public StateResponseProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class FanCommandRequest : public ProtoMessage { +class FanCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 31; - static constexpr uint16_t ESTIMATED_SIZE = 38; + static constexpr uint16_t ESTIMATED_SIZE = 42; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "fan_command_request"; } #endif - uint32_t key{0}; bool has_state{false}; bool state{false}; bool has_speed{false}; @@ -803,14 +810,13 @@ class LightStateResponse : public StateResponseProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class LightCommandRequest : public ProtoMessage { +class LightCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 32; - static constexpr uint16_t ESTIMATED_SIZE = 107; + static constexpr uint16_t ESTIMATED_SIZE = 112; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "light_command_request"; } #endif - uint32_t key{0}; bool has_state{false}; bool state{false}; bool has_brightness{false}; @@ -933,14 +939,13 @@ class SwitchStateResponse : public StateResponseProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class SwitchCommandRequest : public ProtoMessage { +class SwitchCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 33; - static constexpr uint16_t ESTIMATED_SIZE = 7; + static constexpr uint16_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "switch_command_request"; } #endif - uint32_t key{0}; bool state{false}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -1292,14 +1297,13 @@ class ListEntitiesCameraResponse : public InfoResponseProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class CameraImageResponse : public ProtoMessage { +class CameraImageResponse : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 44; - static constexpr uint16_t ESTIMATED_SIZE = 16; + static constexpr uint16_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "camera_image_response"; } #endif - uint32_t key{0}; std::string data{}; bool done{false}; void encode(ProtoWriteBuffer buffer) const override; @@ -1401,14 +1405,13 @@ class ClimateStateResponse : public StateResponseProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ClimateCommandRequest : public ProtoMessage { +class ClimateCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 48; - static constexpr uint16_t ESTIMATED_SIZE = 83; + static constexpr uint16_t ESTIMATED_SIZE = 88; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "climate_command_request"; } #endif - uint32_t key{0}; bool has_mode{false}; enums::ClimateMode mode{}; bool has_target_temperature{false}; @@ -1487,14 +1490,13 @@ class NumberStateResponse : public StateResponseProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class NumberCommandRequest : public ProtoMessage { +class NumberCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 51; - static constexpr uint16_t ESTIMATED_SIZE = 10; + static constexpr uint16_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "number_command_request"; } #endif - uint32_t key{0}; float state{0.0f}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -1504,6 +1506,7 @@ class NumberCommandRequest : public ProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif #ifdef USE_SELECT @@ -1546,14 +1549,13 @@ class SelectStateResponse : public StateResponseProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class SelectCommandRequest : public ProtoMessage { +class SelectCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 54; - static constexpr uint16_t ESTIMATED_SIZE = 14; + static constexpr uint16_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "select_command_request"; } #endif - uint32_t key{0}; std::string state{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -1564,6 +1566,7 @@ class SelectCommandRequest : public ProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif #ifdef USE_SIREN @@ -1606,14 +1609,13 @@ class SirenStateResponse : public StateResponseProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class SirenCommandRequest : public ProtoMessage { +class SirenCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 57; - static constexpr uint16_t ESTIMATED_SIZE = 33; + static constexpr uint16_t ESTIMATED_SIZE = 37; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "siren_command_request"; } #endif - uint32_t key{0}; bool has_state{false}; bool state{false}; bool has_tone{false}; @@ -1675,14 +1677,13 @@ class LockStateResponse : public StateResponseProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class LockCommandRequest : public ProtoMessage { +class LockCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 60; - static constexpr uint16_t ESTIMATED_SIZE = 18; + static constexpr uint16_t ESTIMATED_SIZE = 22; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "lock_command_request"; } #endif - uint32_t key{0}; enums::LockCommand command{}; bool has_code{false}; std::string code{}; @@ -1718,14 +1719,13 @@ class ListEntitiesButtonResponse : public InfoResponseProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ButtonCommandRequest : public ProtoMessage { +class ButtonCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 62; - static constexpr uint16_t ESTIMATED_SIZE = 5; + static constexpr uint16_t ESTIMATED_SIZE = 9; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "button_command_request"; } #endif - uint32_t key{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1734,6 +1734,7 @@ class ButtonCommandRequest : public ProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif #ifdef USE_MEDIA_PLAYER @@ -1794,14 +1795,13 @@ class MediaPlayerStateResponse : public StateResponseProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class MediaPlayerCommandRequest : public ProtoMessage { +class MediaPlayerCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 65; - static constexpr uint16_t ESTIMATED_SIZE = 31; + static constexpr uint16_t ESTIMATED_SIZE = 35; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "media_player_command_request"; } #endif - uint32_t key{0}; bool has_command{false}; enums::MediaPlayerCommand command{}; bool has_volume{false}; @@ -2669,14 +2669,13 @@ class AlarmControlPanelStateResponse : public StateResponseProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class AlarmControlPanelCommandRequest : public ProtoMessage { +class AlarmControlPanelCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 96; - static constexpr uint16_t ESTIMATED_SIZE = 16; + static constexpr uint16_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "alarm_control_panel_command_request"; } #endif - uint32_t key{0}; enums::AlarmControlPanelStateCommand command{}; std::string code{}; void encode(ProtoWriteBuffer buffer) const override; @@ -2734,14 +2733,13 @@ class TextStateResponse : public StateResponseProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class TextCommandRequest : public ProtoMessage { +class TextCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 99; - static constexpr uint16_t ESTIMATED_SIZE = 14; + static constexpr uint16_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "text_command_request"; } #endif - uint32_t key{0}; std::string state{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -2752,6 +2750,7 @@ class TextCommandRequest : public ProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif #ifdef USE_DATETIME_DATE @@ -2794,14 +2793,13 @@ class DateStateResponse : public StateResponseProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class DateCommandRequest : public ProtoMessage { +class DateCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 102; - static constexpr uint16_t ESTIMATED_SIZE = 17; + static constexpr uint16_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "date_command_request"; } #endif - uint32_t key{0}; uint32_t year{0}; uint32_t month{0}; uint32_t day{0}; @@ -2856,14 +2854,13 @@ class TimeStateResponse : public StateResponseProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class TimeCommandRequest : public ProtoMessage { +class TimeCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 105; - static constexpr uint16_t ESTIMATED_SIZE = 17; + static constexpr uint16_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "time_command_request"; } #endif - uint32_t key{0}; uint32_t hour{0}; uint32_t minute{0}; uint32_t second{0}; @@ -2961,14 +2958,13 @@ class ValveStateResponse : public StateResponseProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ValveCommandRequest : public ProtoMessage { +class ValveCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 111; - static constexpr uint16_t ESTIMATED_SIZE = 14; + static constexpr uint16_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "valve_command_request"; } #endif - uint32_t key{0}; bool has_position{false}; float position{0.0f}; bool stop{false}; @@ -3021,14 +3017,13 @@ class DateTimeStateResponse : public StateResponseProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class DateTimeCommandRequest : public ProtoMessage { +class DateTimeCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 114; - static constexpr uint16_t ESTIMATED_SIZE = 10; + static constexpr uint16_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "date_time_command_request"; } #endif - uint32_t key{0}; uint32_t epoch_seconds{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; @@ -3038,6 +3033,7 @@ class DateTimeCommandRequest : public ProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif #ifdef USE_UPDATE @@ -3087,14 +3083,13 @@ class UpdateStateResponse : public StateResponseProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class UpdateCommandRequest : public ProtoMessage { +class UpdateCommandRequest : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 118; - static constexpr uint16_t ESTIMATED_SIZE = 7; + static constexpr uint16_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "update_command_request"; } #endif - uint32_t key{0}; enums::UpdateCommand command{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 48ddd42d61..7991e20bc5 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -986,6 +986,11 @@ void CoverCommandRequest::dump_to(std::string &out) const { out.append(" stop: "); out.append(YESNO(this->stop)); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -1146,6 +1151,11 @@ void FanCommandRequest::dump_to(std::string &out) const { out.append(" preset_mode: "); out.append("'").append(this->preset_mode).append("'"); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -1419,6 +1429,11 @@ void LightCommandRequest::dump_to(std::string &out) const { out.append(" effect: "); out.append("'").append(this->effect).append("'"); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -1586,6 +1601,11 @@ void SwitchCommandRequest::dump_to(std::string &out) const { out.append(" state: "); out.append(YESNO(this->state)); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -1944,6 +1964,11 @@ void CameraImageResponse::dump_to(std::string &out) const { out.append(" done: "); out.append(YESNO(this->done)); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } void CameraImageRequest::dump_to(std::string &out) const { @@ -2263,6 +2288,11 @@ void ClimateCommandRequest::dump_to(std::string &out) const { snprintf(buffer, sizeof(buffer), "%g", this->target_humidity); out.append(buffer); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -2367,6 +2397,11 @@ void NumberCommandRequest::dump_to(std::string &out) const { snprintf(buffer, sizeof(buffer), "%g", this->state); out.append(buffer); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -2448,6 +2483,11 @@ void SelectCommandRequest::dump_to(std::string &out) const { out.append(" state: "); out.append("'").append(this->state).append("'"); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -2563,6 +2603,11 @@ void SirenCommandRequest::dump_to(std::string &out) const { snprintf(buffer, sizeof(buffer), "%g", this->volume); out.append(buffer); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -2658,6 +2703,11 @@ void LockCommandRequest::dump_to(std::string &out) const { out.append(" code: "); out.append("'").append(this->code).append("'"); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -2711,6 +2761,11 @@ void ButtonCommandRequest::dump_to(std::string &out) const { snprintf(buffer, sizeof(buffer), "%" PRIu32, this->key); out.append(buffer); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -2857,6 +2912,11 @@ void MediaPlayerCommandRequest::dump_to(std::string &out) const { out.append(" announcement: "); out.append(YESNO(this->announcement)); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -3682,6 +3742,11 @@ void AlarmControlPanelCommandRequest::dump_to(std::string &out) const { out.append(" code: "); out.append("'").append(this->code).append("'"); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -3775,6 +3840,11 @@ void TextCommandRequest::dump_to(std::string &out) const { out.append(" state: "); out.append("'").append(this->state).append("'"); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -3872,6 +3942,11 @@ void DateCommandRequest::dump_to(std::string &out) const { snprintf(buffer, sizeof(buffer), "%" PRIu32, this->day); out.append(buffer); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -3969,6 +4044,11 @@ void TimeCommandRequest::dump_to(std::string &out) const { snprintf(buffer, sizeof(buffer), "%" PRIu32, this->second); out.append(buffer); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -4138,6 +4218,11 @@ void ValveCommandRequest::dump_to(std::string &out) const { out.append(" stop: "); out.append(YESNO(this->stop)); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -4215,6 +4300,11 @@ void DateTimeCommandRequest::dump_to(std::string &out) const { snprintf(buffer, sizeof(buffer), "%" PRIu32, this->epoch_seconds); out.append(buffer); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif @@ -4323,6 +4413,11 @@ void UpdateCommandRequest::dump_to(std::string &out) const { out.append(" command: "); out.append(proto_enum_to_string(this->command)); out.append("\n"); + + out.append(" device_id: "); + snprintf(buffer, sizeof(buffer), "%" PRIu32, this->device_id); + out.append(buffer); + out.append("\n"); out.append("}"); } #endif From bef20b60d004e3606a7cbf83367b8fe343ab1e6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jul 2025 09:11:45 -1000 Subject: [PATCH 43/51] Fix scheduler crash when cancelling items with NULL names (#9444) --- esphome/core/scheduler.cpp | 21 +++---- esphome/core/scheduler.h | 3 - .../fixtures/scheduler_null_name.yaml | 43 ++++++++++++++ tests/integration/test_scheduler_null_name.py | 59 +++++++++++++++++++ 4 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_null_name.yaml create mode 100644 tests/integration/test_scheduler_null_name.py diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d3da003a88..c6893b128f 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -66,10 +66,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if name is not empty - if (this->is_name_valid_(name_cstr)) { - LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_cstr, type); - } + LockGuard guard{this->lock_}; + this->cancel_item_locked_(component, name_cstr, type); return; } @@ -125,10 +123,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type LockGuard guard{this->lock_}; // If name is provided, do atomic cancel-and-add - if (this->is_name_valid_(name_cstr)) { - // Cancel existing items - this->cancel_item_locked_(component, name_cstr, type); - } + // Cancel existing items + this->cancel_item_locked_(component, name_cstr, type); // Add new item directly to to_add_ // since we have the lock held this->to_add_.push_back(std::move(item)); @@ -442,10 +438,6 @@ bool HOT Scheduler::cancel_item_(Component *component, bool is_static_string, co // Get the name as const char* const char *name_cstr = this->get_name_cstr_(is_static_string, name_ptr); - // Handle null or empty names - if (!this->is_name_valid_(name_cstr)) - return false; - // obtain lock because this function iterates and can be called from non-loop task context LockGuard guard{this->lock_}; return this->cancel_item_locked_(component, name_cstr, type); @@ -453,6 +445,11 @@ bool HOT Scheduler::cancel_item_(Component *component, bool is_static_string, co // Helper to cancel items by name - must be called with lock held bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_cstr, SchedulerItem::Type type) { + // Early return if name is invalid - no items to cancel + if (name_cstr == nullptr || name_cstr[0] == '\0') { + return false; + } + size_t total_cancelled = 0; // Check all containers for matching items diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 084ff699c5..39cee5a876 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -150,9 +150,6 @@ class Scheduler { return is_static_string ? static_cast(name_ptr) : static_cast(name_ptr)->c_str(); } - // Helper to check if a name is valid (not null and not empty) - inline bool is_name_valid_(const char *name) { return name != nullptr && name[0] != '\0'; } - // Common implementation for cancel operations bool cancel_item_(Component *component, bool is_static_string, const void *name_ptr, SchedulerItem::Type type); diff --git a/tests/integration/fixtures/scheduler_null_name.yaml b/tests/integration/fixtures/scheduler_null_name.yaml new file mode 100644 index 0000000000..42eaacdd43 --- /dev/null +++ b/tests/integration/fixtures/scheduler_null_name.yaml @@ -0,0 +1,43 @@ +esphome: + name: scheduler-null-name + +host: + +logger: + level: DEBUG + +api: + services: + - service: test_null_name + then: + - lambda: |- + // First, create a scenario that would trigger the crash + // The crash happens when defer() is called with a name that would be cancelled + + // Test 1: Create a defer with a valid name + App.scheduler.set_timeout(nullptr, "test_defer", 0, []() { + ESP_LOGI("TEST", "First defer should be cancelled"); + }); + + // Test 2: Create another defer with the same name - this triggers cancel_item_locked_ + // In the unfixed code, this would crash if the name was NULL + App.scheduler.set_timeout(nullptr, "test_defer", 0, []() { + ESP_LOGI("TEST", "Second defer executed"); + }); + + // Test 3: Now test with nullptr - this is the actual crash scenario + // Create a defer item without a name (like voice assistant does) + const char* null_name = nullptr; + App.scheduler.set_timeout(nullptr, null_name, 0, []() { + ESP_LOGI("TEST", "Defer with null name executed"); + }); + + // Test 4: Create another defer with null name - this would trigger the crash + App.scheduler.set_timeout(nullptr, null_name, 0, []() { + ESP_LOGI("TEST", "Second null defer executed"); + }); + + // Test 5: Verify scheduler still works + App.scheduler.set_timeout(nullptr, "valid_timeout", 50, []() { + ESP_LOGI("TEST", "Test completed successfully"); + }); diff --git a/tests/integration/test_scheduler_null_name.py b/tests/integration/test_scheduler_null_name.py new file mode 100644 index 0000000000..41bcd8aed7 --- /dev/null +++ b/tests/integration/test_scheduler_null_name.py @@ -0,0 +1,59 @@ +"""Test that scheduler handles NULL names safely without crashing.""" + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_null_name( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that scheduler handles NULL names safely without crashing.""" + + loop = asyncio.get_running_loop() + test_complete_future: asyncio.Future[bool] = loop.create_future() + + # Pattern to match test completion + test_complete_pattern = re.compile(r"Test completed successfully") + + def check_output(line: str) -> None: + """Check log output for test completion.""" + if not test_complete_future.done() and test_complete_pattern.search(line): + test_complete_future.set_result(True) + + async with run_compiled(yaml_config, line_callback=check_output): + async with api_client_connected() as client: + # Verify we can connect + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-null-name" + + # List services + _, services = await asyncio.wait_for( + client.list_entities_services(), timeout=5.0 + ) + + # Find our test service + test_null_name_service = next( + (s for s in services if s.name == "test_null_name"), None + ) + assert test_null_name_service is not None, ( + "test_null_name service not found" + ) + + # Execute the test + client.execute_service(test_null_name_service, {}) + + # Wait for test completion + try: + await asyncio.wait_for(test_complete_future, timeout=10.0) + except asyncio.TimeoutError: + pytest.fail( + "Test did not complete within timeout - likely crashed due to NULL name" + ) From a3806e4de2aa63c9a28596def2c076c4a4439d94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jul 2025 10:01:11 -1000 Subject: [PATCH 44/51] Optimize API performance and flash usage by eliminating runtime message size lookup --- esphome/components/api/api_connection.cpp | 220 ++------- esphome/components/api/api_connection.h | 45 +- esphome/components/api/api_frame_helper.cpp | 4 +- esphome/components/api/api_frame_helper.h | 15 +- esphome/components/api/api_pb2.h | 508 ++++++++++---------- esphome/components/api/proto.h | 4 +- script/api_protobuf/api_protobuf.py | 15 +- 7 files changed, 350 insertions(+), 461 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 537d75467f..49f14c171b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -193,7 +193,8 @@ void APIConnection::loop() { // If we can't send the ping request directly (tx_buffer full), // schedule it at the front of the batch so it will be sent with priority ESP_LOGW(TAG, "Buffer full, ping queued"); - this->schedule_message_front_(nullptr, &APIConnection::try_send_ping_request, PingRequest::MESSAGE_TYPE); + this->schedule_message_front_(nullptr, &APIConnection::try_send_ping_request, PingRequest::MESSAGE_TYPE, + PingRequest::ESTIMATED_SIZE); this->flags_.sent_ping = true; // Mark as sent to avoid scheduling multiple pings } } @@ -265,7 +266,7 @@ void APIConnection::on_disconnect_response(const DisconnectResponse &value) { // Encodes a message to the buffer and returns the total number of bytes used, // including header and footer overhead. Returns 0 if the message doesn't fit. -uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint16_t message_type, APIConnection *conn, +uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t message_type, APIConnection *conn, uint32_t remaining_size, bool is_single) { #ifdef HAS_PROTO_MESSAGE_DUMP // If in log-only mode, just log and return @@ -316,7 +317,7 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint16_t mes #ifdef USE_BINARY_SENSOR bool APIConnection::send_binary_sensor_state(binary_sensor::BinarySensor *binary_sensor) { return this->send_message_smart_(binary_sensor, &APIConnection::try_send_binary_sensor_state, - BinarySensorStateResponse::MESSAGE_TYPE); + BinarySensorStateResponse::MESSAGE_TYPE, BinarySensorStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_binary_sensor_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -343,7 +344,8 @@ uint16_t APIConnection::try_send_binary_sensor_info(EntityBase *entity, APIConne #ifdef USE_COVER bool APIConnection::send_cover_state(cover::Cover *cover) { - return this->send_message_smart_(cover, &APIConnection::try_send_cover_state, CoverStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(cover, &APIConnection::try_send_cover_state, CoverStateResponse::MESSAGE_TYPE, + CoverStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_cover_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -400,7 +402,8 @@ void APIConnection::cover_command(const CoverCommandRequest &msg) { #ifdef USE_FAN bool APIConnection::send_fan_state(fan::Fan *fan) { - return this->send_message_smart_(fan, &APIConnection::try_send_fan_state, FanStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(fan, &APIConnection::try_send_fan_state, FanStateResponse::MESSAGE_TYPE, + FanStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_fan_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -455,7 +458,8 @@ void APIConnection::fan_command(const FanCommandRequest &msg) { #ifdef USE_LIGHT bool APIConnection::send_light_state(light::LightState *light) { - return this->send_message_smart_(light, &APIConnection::try_send_light_state, LightStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(light, &APIConnection::try_send_light_state, LightStateResponse::MESSAGE_TYPE, + LightStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_light_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -543,7 +547,8 @@ void APIConnection::light_command(const LightCommandRequest &msg) { #ifdef USE_SENSOR bool APIConnection::send_sensor_state(sensor::Sensor *sensor) { - return this->send_message_smart_(sensor, &APIConnection::try_send_sensor_state, SensorStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(sensor, &APIConnection::try_send_sensor_state, SensorStateResponse::MESSAGE_TYPE, + SensorStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_sensor_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -575,7 +580,8 @@ uint16_t APIConnection::try_send_sensor_info(EntityBase *entity, APIConnection * #ifdef USE_SWITCH bool APIConnection::send_switch_state(switch_::Switch *a_switch) { - return this->send_message_smart_(a_switch, &APIConnection::try_send_switch_state, SwitchStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(a_switch, &APIConnection::try_send_switch_state, SwitchStateResponse::MESSAGE_TYPE, + SwitchStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_switch_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -611,7 +617,7 @@ void APIConnection::switch_command(const SwitchCommandRequest &msg) { #ifdef USE_TEXT_SENSOR bool APIConnection::send_text_sensor_state(text_sensor::TextSensor *text_sensor) { return this->send_message_smart_(text_sensor, &APIConnection::try_send_text_sensor_state, - TextSensorStateResponse::MESSAGE_TYPE); + TextSensorStateResponse::MESSAGE_TYPE, TextSensorStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_text_sensor_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -638,7 +644,8 @@ uint16_t APIConnection::try_send_text_sensor_info(EntityBase *entity, APIConnect #ifdef USE_CLIMATE bool APIConnection::send_climate_state(climate::Climate *climate) { - return this->send_message_smart_(climate, &APIConnection::try_send_climate_state, ClimateStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(climate, &APIConnection::try_send_climate_state, ClimateStateResponse::MESSAGE_TYPE, + ClimateStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_climate_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -734,7 +741,8 @@ void APIConnection::climate_command(const ClimateCommandRequest &msg) { #ifdef USE_NUMBER bool APIConnection::send_number_state(number::Number *number) { - return this->send_message_smart_(number, &APIConnection::try_send_number_state, NumberStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(number, &APIConnection::try_send_number_state, NumberStateResponse::MESSAGE_TYPE, + NumberStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_number_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -770,7 +778,8 @@ void APIConnection::number_command(const NumberCommandRequest &msg) { #ifdef USE_DATETIME_DATE bool APIConnection::send_date_state(datetime::DateEntity *date) { - return this->send_message_smart_(date, &APIConnection::try_send_date_state, DateStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(date, &APIConnection::try_send_date_state, DateStateResponse::MESSAGE_TYPE, + DateStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_date_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -800,7 +809,8 @@ void APIConnection::date_command(const DateCommandRequest &msg) { #ifdef USE_DATETIME_TIME bool APIConnection::send_time_state(datetime::TimeEntity *time) { - return this->send_message_smart_(time, &APIConnection::try_send_time_state, TimeStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(time, &APIConnection::try_send_time_state, TimeStateResponse::MESSAGE_TYPE, + TimeStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_time_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -831,7 +841,7 @@ void APIConnection::time_command(const TimeCommandRequest &msg) { #ifdef USE_DATETIME_DATETIME bool APIConnection::send_datetime_state(datetime::DateTimeEntity *datetime) { return this->send_message_smart_(datetime, &APIConnection::try_send_datetime_state, - DateTimeStateResponse::MESSAGE_TYPE); + DateTimeStateResponse::MESSAGE_TYPE, DateTimeStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_datetime_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -862,7 +872,8 @@ void APIConnection::datetime_command(const DateTimeCommandRequest &msg) { #ifdef USE_TEXT bool APIConnection::send_text_state(text::Text *text) { - return this->send_message_smart_(text, &APIConnection::try_send_text_state, TextStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(text, &APIConnection::try_send_text_state, TextStateResponse::MESSAGE_TYPE, + TextStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_text_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -896,7 +907,8 @@ void APIConnection::text_command(const TextCommandRequest &msg) { #ifdef USE_SELECT bool APIConnection::send_select_state(select::Select *select) { - return this->send_message_smart_(select, &APIConnection::try_send_select_state, SelectStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(select, &APIConnection::try_send_select_state, SelectStateResponse::MESSAGE_TYPE, + SelectStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_select_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -944,7 +956,8 @@ void esphome::api::APIConnection::button_command(const ButtonCommandRequest &msg #ifdef USE_LOCK bool APIConnection::send_lock_state(lock::Lock *a_lock) { - return this->send_message_smart_(a_lock, &APIConnection::try_send_lock_state, LockStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(a_lock, &APIConnection::try_send_lock_state, LockStateResponse::MESSAGE_TYPE, + LockStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_lock_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, @@ -986,7 +999,8 @@ void APIConnection::lock_command(const LockCommandRequest &msg) { #ifdef USE_VALVE bool APIConnection::send_valve_state(valve::Valve *valve) { - return this->send_message_smart_(valve, &APIConnection::try_send_valve_state, ValveStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(valve, &APIConnection::try_send_valve_state, ValveStateResponse::MESSAGE_TYPE, + ValveStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_valve_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -1023,7 +1037,7 @@ void APIConnection::valve_command(const ValveCommandRequest &msg) { #ifdef USE_MEDIA_PLAYER bool APIConnection::send_media_player_state(media_player::MediaPlayer *media_player) { return this->send_message_smart_(media_player, &APIConnection::try_send_media_player_state, - MediaPlayerStateResponse::MESSAGE_TYPE); + MediaPlayerStateResponse::MESSAGE_TYPE, MediaPlayerStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_media_player_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -1262,7 +1276,8 @@ void APIConnection::voice_assistant_set_configuration(const VoiceAssistantSetCon #ifdef USE_ALARM_CONTROL_PANEL bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) { return this->send_message_smart_(a_alarm_control_panel, &APIConnection::try_send_alarm_control_panel_state, - AlarmControlPanelStateResponse::MESSAGE_TYPE); + AlarmControlPanelStateResponse::MESSAGE_TYPE, + AlarmControlPanelStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_alarm_control_panel_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -1316,7 +1331,8 @@ void APIConnection::alarm_control_panel_command(const AlarmControlPanelCommandRe #ifdef USE_EVENT void APIConnection::send_event(event::Event *event, const std::string &event_type) { - this->schedule_message_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE); + this->schedule_message_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE, + EventResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_event_response(event::Event *event, const std::string &event_type, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -1341,7 +1357,8 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c #ifdef USE_UPDATE bool APIConnection::send_update_state(update::UpdateEntity *update) { - return this->send_message_smart_(update, &APIConnection::try_send_update_state, UpdateStateResponse::MESSAGE_TYPE); + return this->send_message_smart_(update, &APIConnection::try_send_update_state, UpdateStateResponse::MESSAGE_TYPE, + UpdateStateResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_update_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single) { @@ -1588,7 +1605,7 @@ bool APIConnection::try_to_clear_buffer(bool log_out_of_space) { } return false; } -bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) { +bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { if (!this->try_to_clear_buffer(message_type != SubscribeLogsResponse::MESSAGE_TYPE)) { // SubscribeLogsResponse return false; } @@ -1622,7 +1639,8 @@ void APIConnection::on_fatal_error() { this->flags_.remove = true; } -void APIConnection::DeferredBatch::add_item(EntityBase *entity, MessageCreator creator, uint16_t message_type) { +void APIConnection::DeferredBatch::add_item(EntityBase *entity, MessageCreator creator, uint8_t message_type, + uint8_t estimated_size) { // Check if we already have a message of this type for this entity // This provides deduplication per entity/message_type combination // O(n) but optimized for RAM and not performance. @@ -1637,12 +1655,13 @@ void APIConnection::DeferredBatch::add_item(EntityBase *entity, MessageCreator c } // No existing item found, add new one - items.emplace_back(entity, std::move(creator), message_type); + items.emplace_back(entity, std::move(creator), message_type, estimated_size); } -void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, MessageCreator creator, uint16_t message_type) { +void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, MessageCreator creator, uint8_t message_type, + uint8_t estimated_size) { // Insert at front for high priority messages (no deduplication check) - items.insert(items.begin(), BatchItem(entity, std::move(creator), message_type)); + items.insert(items.begin(), BatchItem(entity, std::move(creator), message_type, estimated_size)); } bool APIConnection::schedule_batch_() { @@ -1714,7 +1733,7 @@ void APIConnection::process_batch_() { uint32_t total_estimated_size = 0; for (size_t i = 0; i < this->deferred_batch_.size(); i++) { const auto &item = this->deferred_batch_[i]; - total_estimated_size += get_estimated_message_size(item.message_type); + total_estimated_size += item.estimated_size; } // Calculate total overhead for all messages @@ -1808,7 +1827,7 @@ void APIConnection::process_batch_() { } uint16_t APIConnection::MessageCreator::operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, - bool is_single, uint16_t message_type) const { + bool is_single, uint8_t message_type) const { #ifdef USE_EVENT // Special case: EventResponse uses string pointer if (message_type == EventResponse::MESSAGE_TYPE) { @@ -1839,149 +1858,6 @@ uint16_t APIConnection::try_send_ping_request(EntityBase *entity, APIConnection return encode_message_to_buffer(req, PingRequest::MESSAGE_TYPE, conn, remaining_size, is_single); } -uint16_t APIConnection::get_estimated_message_size(uint16_t message_type) { - // Use generated ESTIMATED_SIZE constants from each message type - switch (message_type) { -#ifdef USE_BINARY_SENSOR - case BinarySensorStateResponse::MESSAGE_TYPE: - return BinarySensorStateResponse::ESTIMATED_SIZE; - case ListEntitiesBinarySensorResponse::MESSAGE_TYPE: - return ListEntitiesBinarySensorResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_SENSOR - case SensorStateResponse::MESSAGE_TYPE: - return SensorStateResponse::ESTIMATED_SIZE; - case ListEntitiesSensorResponse::MESSAGE_TYPE: - return ListEntitiesSensorResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_SWITCH - case SwitchStateResponse::MESSAGE_TYPE: - return SwitchStateResponse::ESTIMATED_SIZE; - case ListEntitiesSwitchResponse::MESSAGE_TYPE: - return ListEntitiesSwitchResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_TEXT_SENSOR - case TextSensorStateResponse::MESSAGE_TYPE: - return TextSensorStateResponse::ESTIMATED_SIZE; - case ListEntitiesTextSensorResponse::MESSAGE_TYPE: - return ListEntitiesTextSensorResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_NUMBER - case NumberStateResponse::MESSAGE_TYPE: - return NumberStateResponse::ESTIMATED_SIZE; - case ListEntitiesNumberResponse::MESSAGE_TYPE: - return ListEntitiesNumberResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_TEXT - case TextStateResponse::MESSAGE_TYPE: - return TextStateResponse::ESTIMATED_SIZE; - case ListEntitiesTextResponse::MESSAGE_TYPE: - return ListEntitiesTextResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_SELECT - case SelectStateResponse::MESSAGE_TYPE: - return SelectStateResponse::ESTIMATED_SIZE; - case ListEntitiesSelectResponse::MESSAGE_TYPE: - return ListEntitiesSelectResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_LOCK - case LockStateResponse::MESSAGE_TYPE: - return LockStateResponse::ESTIMATED_SIZE; - case ListEntitiesLockResponse::MESSAGE_TYPE: - return ListEntitiesLockResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_EVENT - case EventResponse::MESSAGE_TYPE: - return EventResponse::ESTIMATED_SIZE; - case ListEntitiesEventResponse::MESSAGE_TYPE: - return ListEntitiesEventResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_COVER - case CoverStateResponse::MESSAGE_TYPE: - return CoverStateResponse::ESTIMATED_SIZE; - case ListEntitiesCoverResponse::MESSAGE_TYPE: - return ListEntitiesCoverResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_FAN - case FanStateResponse::MESSAGE_TYPE: - return FanStateResponse::ESTIMATED_SIZE; - case ListEntitiesFanResponse::MESSAGE_TYPE: - return ListEntitiesFanResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_LIGHT - case LightStateResponse::MESSAGE_TYPE: - return LightStateResponse::ESTIMATED_SIZE; - case ListEntitiesLightResponse::MESSAGE_TYPE: - return ListEntitiesLightResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_CLIMATE - case ClimateStateResponse::MESSAGE_TYPE: - return ClimateStateResponse::ESTIMATED_SIZE; - case ListEntitiesClimateResponse::MESSAGE_TYPE: - return ListEntitiesClimateResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_CAMERA - case ListEntitiesCameraResponse::MESSAGE_TYPE: - return ListEntitiesCameraResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_BUTTON - case ListEntitiesButtonResponse::MESSAGE_TYPE: - return ListEntitiesButtonResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_MEDIA_PLAYER - case MediaPlayerStateResponse::MESSAGE_TYPE: - return MediaPlayerStateResponse::ESTIMATED_SIZE; - case ListEntitiesMediaPlayerResponse::MESSAGE_TYPE: - return ListEntitiesMediaPlayerResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_ALARM_CONTROL_PANEL - case AlarmControlPanelStateResponse::MESSAGE_TYPE: - return AlarmControlPanelStateResponse::ESTIMATED_SIZE; - case ListEntitiesAlarmControlPanelResponse::MESSAGE_TYPE: - return ListEntitiesAlarmControlPanelResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_DATETIME_DATE - case DateStateResponse::MESSAGE_TYPE: - return DateStateResponse::ESTIMATED_SIZE; - case ListEntitiesDateResponse::MESSAGE_TYPE: - return ListEntitiesDateResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_DATETIME_TIME - case TimeStateResponse::MESSAGE_TYPE: - return TimeStateResponse::ESTIMATED_SIZE; - case ListEntitiesTimeResponse::MESSAGE_TYPE: - return ListEntitiesTimeResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_DATETIME_DATETIME - case DateTimeStateResponse::MESSAGE_TYPE: - return DateTimeStateResponse::ESTIMATED_SIZE; - case ListEntitiesDateTimeResponse::MESSAGE_TYPE: - return ListEntitiesDateTimeResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_VALVE - case ValveStateResponse::MESSAGE_TYPE: - return ValveStateResponse::ESTIMATED_SIZE; - case ListEntitiesValveResponse::MESSAGE_TYPE: - return ListEntitiesValveResponse::ESTIMATED_SIZE; -#endif -#ifdef USE_UPDATE - case UpdateStateResponse::MESSAGE_TYPE: - return UpdateStateResponse::ESTIMATED_SIZE; - case ListEntitiesUpdateResponse::MESSAGE_TYPE: - return ListEntitiesUpdateResponse::ESTIMATED_SIZE; -#endif - case ListEntitiesServicesResponse::MESSAGE_TYPE: - return ListEntitiesServicesResponse::ESTIMATED_SIZE; - case ListEntitiesDoneResponse::MESSAGE_TYPE: - return ListEntitiesDoneResponse::ESTIMATED_SIZE; - case DisconnectRequest::MESSAGE_TYPE: - return DisconnectRequest::ESTIMATED_SIZE; - default: - // Fallback for unknown message types - return 24; - } -} - } // namespace api } // namespace esphome #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index b70b037999..83a8c10e43 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -33,7 +33,7 @@ class APIConnection : public APIServerConnection { bool send_list_info_done() { return this->schedule_message_(nullptr, &APIConnection::try_send_list_info_done, - ListEntitiesDoneResponse::MESSAGE_TYPE); + ListEntitiesDoneResponse::MESSAGE_TYPE, ListEntitiesDoneResponse::ESTIMATED_SIZE); } #ifdef USE_BINARY_SENSOR bool send_binary_sensor_state(binary_sensor::BinarySensor *binary_sensor); @@ -256,7 +256,7 @@ class APIConnection : public APIServerConnection { } bool try_to_clear_buffer(bool log_out_of_space); - bool send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) override; + bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override; std::string get_client_combined_info() const { if (this->client_info_ == this->client_peername_) { @@ -298,7 +298,7 @@ class APIConnection : public APIServerConnection { } // Non-template helper to encode any ProtoMessage - static uint16_t encode_message_to_buffer(ProtoMessage &msg, uint16_t message_type, APIConnection *conn, + static uint16_t encode_message_to_buffer(ProtoMessage &msg, uint8_t message_type, APIConnection *conn, uint32_t remaining_size, bool is_single); #ifdef USE_VOICE_ASSISTANT @@ -443,9 +443,6 @@ class APIConnection : public APIServerConnection { static uint16_t try_send_disconnect_request(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single); - // Helper function to get estimated message size for buffer pre-allocation - static uint16_t get_estimated_message_size(uint16_t message_type); - // Batch message method for ping requests static uint16_t try_send_ping_request(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single); @@ -505,10 +502,10 @@ class APIConnection : public APIServerConnection { // Call operator - uses message_type to determine union type uint16_t operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single, - uint16_t message_type) const; + uint8_t message_type) const; // Manual cleanup method - must be called before destruction for string types - void cleanup(uint16_t message_type) { + void cleanup(uint8_t message_type) { #ifdef USE_EVENT if (message_type == EventResponse::MESSAGE_TYPE && data_.string_ptr != nullptr) { delete data_.string_ptr; @@ -529,11 +526,12 @@ class APIConnection : public APIServerConnection { struct BatchItem { EntityBase *entity; // Entity pointer MessageCreator creator; // Function that creates the message when needed - uint16_t message_type; // Message type for overhead calculation + uint8_t message_type; // Message type for overhead calculation (max 255) + uint8_t estimated_size; // Estimated message size (max 255 bytes) // Constructor for creating BatchItem - BatchItem(EntityBase *entity, MessageCreator creator, uint16_t message_type) - : entity(entity), creator(std::move(creator)), message_type(message_type) {} + BatchItem(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) + : entity(entity), creator(std::move(creator)), message_type(message_type), estimated_size(estimated_size) {} }; std::vector items; @@ -559,9 +557,9 @@ class APIConnection : public APIServerConnection { } // Add item to the batch - void add_item(EntityBase *entity, MessageCreator creator, uint16_t message_type); + void add_item(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size); // Add item to the front of the batch (for high priority messages like ping) - void add_item_front(EntityBase *entity, MessageCreator creator, uint16_t message_type); + void add_item_front(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size); // Clear all items with proper cleanup void clear() { @@ -641,7 +639,7 @@ class APIConnection : public APIServerConnection { #ifdef HAS_PROTO_MESSAGE_DUMP // Helper to log a proto message from a MessageCreator object - void log_proto_message_(EntityBase *entity, const MessageCreator &creator, uint16_t message_type) { + void log_proto_message_(EntityBase *entity, const MessageCreator &creator, uint8_t message_type) { this->flags_.log_only_mode = true; creator(entity, this, MAX_PACKET_SIZE, true, message_type); this->flags_.log_only_mode = false; @@ -654,7 +652,8 @@ class APIConnection : public APIServerConnection { #endif // Helper method to send a message either immediately or via batching - bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint16_t message_type) { + bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type, + uint8_t estimated_size) { // Try to send immediately if: // 1. We should try to send immediately (should_try_send_immediately = true) // 2. Batch delay is 0 (user has opted in to immediate sending) @@ -675,23 +674,25 @@ class APIConnection : public APIServerConnection { } // Fall back to scheduled batching - return this->schedule_message_(entity, creator, message_type); + return this->schedule_message_(entity, creator, message_type, estimated_size); } // Helper function to schedule a deferred message with known message type - bool schedule_message_(EntityBase *entity, MessageCreator creator, uint16_t message_type) { - this->deferred_batch_.add_item(entity, std::move(creator), message_type); + bool schedule_message_(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) { + this->deferred_batch_.add_item(entity, std::move(creator), message_type, estimated_size); return this->schedule_batch_(); } // Overload for function pointers (for info messages and current state reads) - bool schedule_message_(EntityBase *entity, MessageCreatorPtr function_ptr, uint16_t message_type) { - return schedule_message_(entity, MessageCreator(function_ptr), message_type); + bool schedule_message_(EntityBase *entity, MessageCreatorPtr function_ptr, uint8_t message_type, + uint8_t estimated_size) { + return schedule_message_(entity, MessageCreator(function_ptr), message_type, estimated_size); } // Helper function to schedule a high priority message at the front of the batch - bool schedule_message_front_(EntityBase *entity, MessageCreatorPtr function_ptr, uint16_t message_type) { - this->deferred_batch_.add_item_front(entity, MessageCreator(function_ptr), message_type); + bool schedule_message_front_(EntityBase *entity, MessageCreatorPtr function_ptr, uint8_t message_type, + uint8_t estimated_size) { + this->deferred_batch_.add_item_front(entity, MessageCreator(function_ptr), message_type, estimated_size); return this->schedule_batch_(); } }; diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 2f5acc3bfa..156fd42cb3 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -613,7 +613,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = type; return APIError::OK; } -APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) { +APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { // Resize to include MAC space (required for Noise encryption) buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_); PacketInfo packet{type, 0, @@ -1002,7 +1002,7 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = rx_header_parsed_type_; return APIError::OK; } -APIError APIPlaintextFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) { +APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { PacketInfo packet{type, 0, static_cast(buffer.get_buffer()->size() - frame_header_padding_)}; return write_protobuf_packets(buffer, std::span(&packet, 1)); } diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index eae83a3484..ba3705865f 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -30,13 +30,14 @@ struct ReadPacketBuffer { // Packed packet info structure to minimize memory usage struct PacketInfo { - uint16_t message_type; // 2 bytes + uint8_t message_type; // 1 byte (max 255 message types) + uint8_t padding1; // 1 byte (for alignment) uint16_t offset; // 2 bytes (sufficient for packet size ~1460 bytes) uint16_t payload_size; // 2 bytes (up to 65535 bytes) - uint16_t padding; // 2 byte (for alignment) + uint16_t padding2; // 2 bytes (for alignment to 8 bytes) - PacketInfo(uint16_t type, uint16_t off, uint16_t size) - : message_type(type), offset(off), payload_size(size), padding(0) {} + PacketInfo(uint8_t type, uint16_t off, uint16_t size) + : message_type(type), padding1(0), offset(off), payload_size(size), padding2(0) {} }; enum class APIError : uint16_t { @@ -98,7 +99,7 @@ class APIFrameHelper { } // Give this helper a name for logging void set_log_info(std::string info) { info_ = std::move(info); } - virtual APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) = 0; + virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0; // Write multiple protobuf packets in a single operation // packets contains (message_type, offset, length) for each message in the buffer // The buffer contains all messages with appropriate padding before each @@ -197,7 +198,7 @@ class APINoiseFrameHelper : public APIFrameHelper { APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; - APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override; + APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_packets(ProtoWriteBuffer buffer, std::span packets) override; // Get the frame header padding required by this protocol uint8_t frame_header_padding() override { return frame_header_padding_; } @@ -251,7 +252,7 @@ class APIPlaintextFrameHelper : public APIFrameHelper { APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; - APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override; + APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_packets(ProtoWriteBuffer buffer, std::span packets) override; uint8_t frame_header_padding() override { return frame_header_padding_; } // Get the frame footer size required by this protocol diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 029f22dfc2..3c4e0dfb6d 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -318,8 +318,8 @@ class CommandProtoMessage : public ProtoMessage { }; class HelloRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 1; - static constexpr uint16_t ESTIMATED_SIZE = 17; + static constexpr uint8_t MESSAGE_TYPE = 1; + static constexpr uint8_t ESTIMATED_SIZE = 17; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "hello_request"; } #endif @@ -338,8 +338,8 @@ class HelloRequest : public ProtoMessage { }; class HelloResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 2; - static constexpr uint16_t ESTIMATED_SIZE = 26; + static constexpr uint8_t MESSAGE_TYPE = 2; + static constexpr uint8_t ESTIMATED_SIZE = 26; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "hello_response"; } #endif @@ -359,8 +359,8 @@ class HelloResponse : public ProtoMessage { }; class ConnectRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 3; - static constexpr uint16_t ESTIMATED_SIZE = 9; + static constexpr uint8_t MESSAGE_TYPE = 3; + static constexpr uint8_t ESTIMATED_SIZE = 9; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "connect_request"; } #endif @@ -376,8 +376,8 @@ class ConnectRequest : public ProtoMessage { }; class ConnectResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 4; - static constexpr uint16_t ESTIMATED_SIZE = 2; + static constexpr uint8_t MESSAGE_TYPE = 4; + static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "connect_response"; } #endif @@ -393,8 +393,8 @@ class ConnectResponse : public ProtoMessage { }; class DisconnectRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 5; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 5; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "disconnect_request"; } #endif @@ -406,8 +406,8 @@ class DisconnectRequest : public ProtoMessage { }; class DisconnectResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 6; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 6; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "disconnect_response"; } #endif @@ -419,8 +419,8 @@ class DisconnectResponse : public ProtoMessage { }; class PingRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 7; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 7; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "ping_request"; } #endif @@ -432,8 +432,8 @@ class PingRequest : public ProtoMessage { }; class PingResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 8; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 8; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "ping_response"; } #endif @@ -445,8 +445,8 @@ class PingResponse : public ProtoMessage { }; class DeviceInfoRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 9; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 9; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "device_info_request"; } #endif @@ -487,8 +487,8 @@ class DeviceInfo : public ProtoMessage { }; class DeviceInfoResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 219; + static constexpr uint8_t MESSAGE_TYPE = 10; + static constexpr uint8_t ESTIMATED_SIZE = 219; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "device_info_response"; } #endif @@ -526,8 +526,8 @@ class DeviceInfoResponse : public ProtoMessage { }; class ListEntitiesRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 11; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 11; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_request"; } #endif @@ -539,8 +539,8 @@ class ListEntitiesRequest : public ProtoMessage { }; class ListEntitiesDoneResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 19; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 19; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_done_response"; } #endif @@ -552,8 +552,8 @@ class ListEntitiesDoneResponse : public ProtoMessage { }; class SubscribeStatesRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 20; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 20; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "subscribe_states_request"; } #endif @@ -566,8 +566,8 @@ class SubscribeStatesRequest : public ProtoMessage { #ifdef USE_BINARY_SENSOR class ListEntitiesBinarySensorResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 12; - static constexpr uint16_t ESTIMATED_SIZE = 60; + static constexpr uint8_t MESSAGE_TYPE = 12; + static constexpr uint8_t ESTIMATED_SIZE = 60; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_binary_sensor_response"; } #endif @@ -586,8 +586,8 @@ class ListEntitiesBinarySensorResponse : public InfoResponseProtoMessage { }; class BinarySensorStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 21; - static constexpr uint16_t ESTIMATED_SIZE = 13; + static constexpr uint8_t MESSAGE_TYPE = 21; + static constexpr uint8_t ESTIMATED_SIZE = 13; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "binary_sensor_state_response"; } #endif @@ -607,8 +607,8 @@ class BinarySensorStateResponse : public StateResponseProtoMessage { #ifdef USE_COVER class ListEntitiesCoverResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 13; - static constexpr uint16_t ESTIMATED_SIZE = 66; + static constexpr uint8_t MESSAGE_TYPE = 13; + static constexpr uint8_t ESTIMATED_SIZE = 66; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_cover_response"; } #endif @@ -630,8 +630,8 @@ class ListEntitiesCoverResponse : public InfoResponseProtoMessage { }; class CoverStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 22; - static constexpr uint16_t ESTIMATED_SIZE = 23; + static constexpr uint8_t MESSAGE_TYPE = 22; + static constexpr uint8_t ESTIMATED_SIZE = 23; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "cover_state_response"; } #endif @@ -651,8 +651,8 @@ class CoverStateResponse : public StateResponseProtoMessage { }; class CoverCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 30; - static constexpr uint16_t ESTIMATED_SIZE = 29; + static constexpr uint8_t MESSAGE_TYPE = 30; + static constexpr uint8_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "cover_command_request"; } #endif @@ -677,8 +677,8 @@ class CoverCommandRequest : public CommandProtoMessage { #ifdef USE_FAN class ListEntitiesFanResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 14; - static constexpr uint16_t ESTIMATED_SIZE = 77; + static constexpr uint8_t MESSAGE_TYPE = 14; + static constexpr uint8_t ESTIMATED_SIZE = 77; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_fan_response"; } #endif @@ -700,8 +700,8 @@ class ListEntitiesFanResponse : public InfoResponseProtoMessage { }; class FanStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 23; - static constexpr uint16_t ESTIMATED_SIZE = 30; + static constexpr uint8_t MESSAGE_TYPE = 23; + static constexpr uint8_t ESTIMATED_SIZE = 30; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "fan_state_response"; } #endif @@ -724,8 +724,8 @@ class FanStateResponse : public StateResponseProtoMessage { }; class FanCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 31; - static constexpr uint16_t ESTIMATED_SIZE = 42; + static constexpr uint8_t MESSAGE_TYPE = 31; + static constexpr uint8_t ESTIMATED_SIZE = 42; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "fan_command_request"; } #endif @@ -756,8 +756,8 @@ class FanCommandRequest : public CommandProtoMessage { #ifdef USE_LIGHT class ListEntitiesLightResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 15; - static constexpr uint16_t ESTIMATED_SIZE = 90; + static constexpr uint8_t MESSAGE_TYPE = 15; + static constexpr uint8_t ESTIMATED_SIZE = 90; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_light_response"; } #endif @@ -782,8 +782,8 @@ class ListEntitiesLightResponse : public InfoResponseProtoMessage { }; class LightStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 24; - static constexpr uint16_t ESTIMATED_SIZE = 67; + static constexpr uint8_t MESSAGE_TYPE = 24; + static constexpr uint8_t ESTIMATED_SIZE = 67; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "light_state_response"; } #endif @@ -812,8 +812,8 @@ class LightStateResponse : public StateResponseProtoMessage { }; class LightCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 32; - static constexpr uint16_t ESTIMATED_SIZE = 112; + static constexpr uint8_t MESSAGE_TYPE = 32; + static constexpr uint8_t ESTIMATED_SIZE = 112; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "light_command_request"; } #endif @@ -858,8 +858,8 @@ class LightCommandRequest : public CommandProtoMessage { #ifdef USE_SENSOR class ListEntitiesSensorResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 16; - static constexpr uint16_t ESTIMATED_SIZE = 77; + static constexpr uint8_t MESSAGE_TYPE = 16; + static constexpr uint8_t ESTIMATED_SIZE = 77; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_sensor_response"; } #endif @@ -882,8 +882,8 @@ class ListEntitiesSensorResponse : public InfoResponseProtoMessage { }; class SensorStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 25; - static constexpr uint16_t ESTIMATED_SIZE = 16; + static constexpr uint8_t MESSAGE_TYPE = 25; + static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "sensor_state_response"; } #endif @@ -903,8 +903,8 @@ class SensorStateResponse : public StateResponseProtoMessage { #ifdef USE_SWITCH class ListEntitiesSwitchResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 17; - static constexpr uint16_t ESTIMATED_SIZE = 60; + static constexpr uint8_t MESSAGE_TYPE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 60; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_switch_response"; } #endif @@ -923,8 +923,8 @@ class ListEntitiesSwitchResponse : public InfoResponseProtoMessage { }; class SwitchStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 26; - static constexpr uint16_t ESTIMATED_SIZE = 11; + static constexpr uint8_t MESSAGE_TYPE = 26; + static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "switch_state_response"; } #endif @@ -941,8 +941,8 @@ class SwitchStateResponse : public StateResponseProtoMessage { }; class SwitchCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 33; - static constexpr uint16_t ESTIMATED_SIZE = 11; + static constexpr uint8_t MESSAGE_TYPE = 33; + static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "switch_command_request"; } #endif @@ -961,8 +961,8 @@ class SwitchCommandRequest : public CommandProtoMessage { #ifdef USE_TEXT_SENSOR class ListEntitiesTextSensorResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 18; - static constexpr uint16_t ESTIMATED_SIZE = 58; + static constexpr uint8_t MESSAGE_TYPE = 18; + static constexpr uint8_t ESTIMATED_SIZE = 58; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_text_sensor_response"; } #endif @@ -980,8 +980,8 @@ class ListEntitiesTextSensorResponse : public InfoResponseProtoMessage { }; class TextSensorStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 27; - static constexpr uint16_t ESTIMATED_SIZE = 20; + static constexpr uint8_t MESSAGE_TYPE = 27; + static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "text_sensor_state_response"; } #endif @@ -1001,8 +1001,8 @@ class TextSensorStateResponse : public StateResponseProtoMessage { #endif class SubscribeLogsRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 28; - static constexpr uint16_t ESTIMATED_SIZE = 4; + static constexpr uint8_t MESSAGE_TYPE = 28; + static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "subscribe_logs_request"; } #endif @@ -1019,8 +1019,8 @@ class SubscribeLogsRequest : public ProtoMessage { }; class SubscribeLogsResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 29; - static constexpr uint16_t ESTIMATED_SIZE = 13; + static constexpr uint8_t MESSAGE_TYPE = 29; + static constexpr uint8_t ESTIMATED_SIZE = 13; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "subscribe_logs_response"; } #endif @@ -1040,8 +1040,8 @@ class SubscribeLogsResponse : public ProtoMessage { #ifdef USE_API_NOISE class NoiseEncryptionSetKeyRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 124; - static constexpr uint16_t ESTIMATED_SIZE = 9; + static constexpr uint8_t MESSAGE_TYPE = 124; + static constexpr uint8_t ESTIMATED_SIZE = 9; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "noise_encryption_set_key_request"; } #endif @@ -1057,8 +1057,8 @@ class NoiseEncryptionSetKeyRequest : public ProtoMessage { }; class NoiseEncryptionSetKeyResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 125; - static constexpr uint16_t ESTIMATED_SIZE = 2; + static constexpr uint8_t MESSAGE_TYPE = 125; + static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "noise_encryption_set_key_response"; } #endif @@ -1075,8 +1075,8 @@ class NoiseEncryptionSetKeyResponse : public ProtoMessage { #endif class SubscribeHomeassistantServicesRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 34; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 34; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "subscribe_homeassistant_services_request"; } #endif @@ -1101,8 +1101,8 @@ class HomeassistantServiceMap : public ProtoMessage { }; class HomeassistantServiceResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 35; - static constexpr uint16_t ESTIMATED_SIZE = 113; + static constexpr uint8_t MESSAGE_TYPE = 35; + static constexpr uint8_t ESTIMATED_SIZE = 113; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "homeassistant_service_response"; } #endif @@ -1123,8 +1123,8 @@ class HomeassistantServiceResponse : public ProtoMessage { }; class SubscribeHomeAssistantStatesRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 38; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 38; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "subscribe_home_assistant_states_request"; } #endif @@ -1136,8 +1136,8 @@ class SubscribeHomeAssistantStatesRequest : public ProtoMessage { }; class SubscribeHomeAssistantStateResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 39; - static constexpr uint16_t ESTIMATED_SIZE = 20; + static constexpr uint8_t MESSAGE_TYPE = 39; + static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "subscribe_home_assistant_state_response"; } #endif @@ -1156,8 +1156,8 @@ class SubscribeHomeAssistantStateResponse : public ProtoMessage { }; class HomeAssistantStateResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 40; - static constexpr uint16_t ESTIMATED_SIZE = 27; + static constexpr uint8_t MESSAGE_TYPE = 40; + static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "home_assistant_state_response"; } #endif @@ -1175,8 +1175,8 @@ class HomeAssistantStateResponse : public ProtoMessage { }; class GetTimeRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 36; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 36; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "get_time_request"; } #endif @@ -1188,8 +1188,8 @@ class GetTimeRequest : public ProtoMessage { }; class GetTimeResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 37; - static constexpr uint16_t ESTIMATED_SIZE = 5; + static constexpr uint8_t MESSAGE_TYPE = 37; + static constexpr uint8_t ESTIMATED_SIZE = 5; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "get_time_response"; } #endif @@ -1219,8 +1219,8 @@ class ListEntitiesServicesArgument : public ProtoMessage { }; class ListEntitiesServicesResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 41; - static constexpr uint16_t ESTIMATED_SIZE = 48; + static constexpr uint8_t MESSAGE_TYPE = 41; + static constexpr uint8_t ESTIMATED_SIZE = 48; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_services_response"; } #endif @@ -1261,8 +1261,8 @@ class ExecuteServiceArgument : public ProtoMessage { }; class ExecuteServiceRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 42; - static constexpr uint16_t ESTIMATED_SIZE = 39; + static constexpr uint8_t MESSAGE_TYPE = 42; + static constexpr uint8_t ESTIMATED_SIZE = 39; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "execute_service_request"; } #endif @@ -1281,8 +1281,8 @@ class ExecuteServiceRequest : public ProtoMessage { #ifdef USE_CAMERA class ListEntitiesCameraResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 43; - static constexpr uint16_t ESTIMATED_SIZE = 49; + static constexpr uint8_t MESSAGE_TYPE = 43; + static constexpr uint8_t ESTIMATED_SIZE = 49; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_camera_response"; } #endif @@ -1299,8 +1299,8 @@ class ListEntitiesCameraResponse : public InfoResponseProtoMessage { }; class CameraImageResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 44; - static constexpr uint16_t ESTIMATED_SIZE = 20; + static constexpr uint8_t MESSAGE_TYPE = 44; + static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "camera_image_response"; } #endif @@ -1319,8 +1319,8 @@ class CameraImageResponse : public StateResponseProtoMessage { }; class CameraImageRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 45; - static constexpr uint16_t ESTIMATED_SIZE = 4; + static constexpr uint8_t MESSAGE_TYPE = 45; + static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "camera_image_request"; } #endif @@ -1339,8 +1339,8 @@ class CameraImageRequest : public ProtoMessage { #ifdef USE_CLIMATE class ListEntitiesClimateResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 46; - static constexpr uint16_t ESTIMATED_SIZE = 156; + static constexpr uint8_t MESSAGE_TYPE = 46; + static constexpr uint8_t ESTIMATED_SIZE = 156; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_climate_response"; } #endif @@ -1375,8 +1375,8 @@ class ListEntitiesClimateResponse : public InfoResponseProtoMessage { }; class ClimateStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 47; - static constexpr uint16_t ESTIMATED_SIZE = 70; + static constexpr uint8_t MESSAGE_TYPE = 47; + static constexpr uint8_t ESTIMATED_SIZE = 70; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "climate_state_response"; } #endif @@ -1407,8 +1407,8 @@ class ClimateStateResponse : public StateResponseProtoMessage { }; class ClimateCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 48; - static constexpr uint16_t ESTIMATED_SIZE = 88; + static constexpr uint8_t MESSAGE_TYPE = 48; + static constexpr uint8_t ESTIMATED_SIZE = 88; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "climate_command_request"; } #endif @@ -1449,8 +1449,8 @@ class ClimateCommandRequest : public CommandProtoMessage { #ifdef USE_NUMBER class ListEntitiesNumberResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 49; - static constexpr uint16_t ESTIMATED_SIZE = 84; + static constexpr uint8_t MESSAGE_TYPE = 49; + static constexpr uint8_t ESTIMATED_SIZE = 84; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_number_response"; } #endif @@ -1473,8 +1473,8 @@ class ListEntitiesNumberResponse : public InfoResponseProtoMessage { }; class NumberStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 50; - static constexpr uint16_t ESTIMATED_SIZE = 16; + static constexpr uint8_t MESSAGE_TYPE = 50; + static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "number_state_response"; } #endif @@ -1492,8 +1492,8 @@ class NumberStateResponse : public StateResponseProtoMessage { }; class NumberCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 51; - static constexpr uint16_t ESTIMATED_SIZE = 14; + static constexpr uint8_t MESSAGE_TYPE = 51; + static constexpr uint8_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "number_command_request"; } #endif @@ -1512,8 +1512,8 @@ class NumberCommandRequest : public CommandProtoMessage { #ifdef USE_SELECT class ListEntitiesSelectResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 52; - static constexpr uint16_t ESTIMATED_SIZE = 67; + static constexpr uint8_t MESSAGE_TYPE = 52; + static constexpr uint8_t ESTIMATED_SIZE = 67; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_select_response"; } #endif @@ -1531,8 +1531,8 @@ class ListEntitiesSelectResponse : public InfoResponseProtoMessage { }; class SelectStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 53; - static constexpr uint16_t ESTIMATED_SIZE = 20; + static constexpr uint8_t MESSAGE_TYPE = 53; + static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "select_state_response"; } #endif @@ -1551,8 +1551,8 @@ class SelectStateResponse : public StateResponseProtoMessage { }; class SelectCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 54; - static constexpr uint16_t ESTIMATED_SIZE = 18; + static constexpr uint8_t MESSAGE_TYPE = 54; + static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "select_command_request"; } #endif @@ -1572,8 +1572,8 @@ class SelectCommandRequest : public CommandProtoMessage { #ifdef USE_SIREN class ListEntitiesSirenResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 55; - static constexpr uint16_t ESTIMATED_SIZE = 71; + static constexpr uint8_t MESSAGE_TYPE = 55; + static constexpr uint8_t ESTIMATED_SIZE = 71; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_siren_response"; } #endif @@ -1593,8 +1593,8 @@ class ListEntitiesSirenResponse : public InfoResponseProtoMessage { }; class SirenStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 56; - static constexpr uint16_t ESTIMATED_SIZE = 11; + static constexpr uint8_t MESSAGE_TYPE = 56; + static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "siren_state_response"; } #endif @@ -1611,8 +1611,8 @@ class SirenStateResponse : public StateResponseProtoMessage { }; class SirenCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 57; - static constexpr uint16_t ESTIMATED_SIZE = 37; + static constexpr uint8_t MESSAGE_TYPE = 57; + static constexpr uint8_t ESTIMATED_SIZE = 37; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "siren_command_request"; } #endif @@ -1639,8 +1639,8 @@ class SirenCommandRequest : public CommandProtoMessage { #ifdef USE_LOCK class ListEntitiesLockResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 58; - static constexpr uint16_t ESTIMATED_SIZE = 64; + static constexpr uint8_t MESSAGE_TYPE = 58; + static constexpr uint8_t ESTIMATED_SIZE = 64; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_lock_response"; } #endif @@ -1661,8 +1661,8 @@ class ListEntitiesLockResponse : public InfoResponseProtoMessage { }; class LockStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 59; - static constexpr uint16_t ESTIMATED_SIZE = 11; + static constexpr uint8_t MESSAGE_TYPE = 59; + static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "lock_state_response"; } #endif @@ -1679,8 +1679,8 @@ class LockStateResponse : public StateResponseProtoMessage { }; class LockCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 60; - static constexpr uint16_t ESTIMATED_SIZE = 22; + static constexpr uint8_t MESSAGE_TYPE = 60; + static constexpr uint8_t ESTIMATED_SIZE = 22; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "lock_command_request"; } #endif @@ -1702,8 +1702,8 @@ class LockCommandRequest : public CommandProtoMessage { #ifdef USE_BUTTON class ListEntitiesButtonResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 61; - static constexpr uint16_t ESTIMATED_SIZE = 58; + static constexpr uint8_t MESSAGE_TYPE = 61; + static constexpr uint8_t ESTIMATED_SIZE = 58; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_button_response"; } #endif @@ -1721,8 +1721,8 @@ class ListEntitiesButtonResponse : public InfoResponseProtoMessage { }; class ButtonCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 62; - static constexpr uint16_t ESTIMATED_SIZE = 9; + static constexpr uint8_t MESSAGE_TYPE = 62; + static constexpr uint8_t ESTIMATED_SIZE = 9; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "button_command_request"; } #endif @@ -1757,8 +1757,8 @@ class MediaPlayerSupportedFormat : public ProtoMessage { }; class ListEntitiesMediaPlayerResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 63; - static constexpr uint16_t ESTIMATED_SIZE = 85; + static constexpr uint8_t MESSAGE_TYPE = 63; + static constexpr uint8_t ESTIMATED_SIZE = 85; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_media_player_response"; } #endif @@ -1777,8 +1777,8 @@ class ListEntitiesMediaPlayerResponse : public InfoResponseProtoMessage { }; class MediaPlayerStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 64; - static constexpr uint16_t ESTIMATED_SIZE = 18; + static constexpr uint8_t MESSAGE_TYPE = 64; + static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "media_player_state_response"; } #endif @@ -1797,8 +1797,8 @@ class MediaPlayerStateResponse : public StateResponseProtoMessage { }; class MediaPlayerCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 65; - static constexpr uint16_t ESTIMATED_SIZE = 35; + static constexpr uint8_t MESSAGE_TYPE = 65; + static constexpr uint8_t ESTIMATED_SIZE = 35; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "media_player_command_request"; } #endif @@ -1825,8 +1825,8 @@ class MediaPlayerCommandRequest : public CommandProtoMessage { #ifdef USE_BLUETOOTH_PROXY class SubscribeBluetoothLEAdvertisementsRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 66; - static constexpr uint16_t ESTIMATED_SIZE = 4; + static constexpr uint8_t MESSAGE_TYPE = 66; + static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "subscribe_bluetooth_le_advertisements_request"; } #endif @@ -1857,8 +1857,8 @@ class BluetoothServiceData : public ProtoMessage { }; class BluetoothLEAdvertisementResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 67; - static constexpr uint16_t ESTIMATED_SIZE = 107; + static constexpr uint8_t MESSAGE_TYPE = 67; + static constexpr uint8_t ESTIMATED_SIZE = 107; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_le_advertisement_response"; } #endif @@ -1897,8 +1897,8 @@ class BluetoothLERawAdvertisement : public ProtoMessage { }; class BluetoothLERawAdvertisementsResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 93; - static constexpr uint16_t ESTIMATED_SIZE = 34; + static constexpr uint8_t MESSAGE_TYPE = 93; + static constexpr uint8_t ESTIMATED_SIZE = 34; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_le_raw_advertisements_response"; } #endif @@ -1914,8 +1914,8 @@ class BluetoothLERawAdvertisementsResponse : public ProtoMessage { }; class BluetoothDeviceRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 68; - static constexpr uint16_t ESTIMATED_SIZE = 12; + static constexpr uint8_t MESSAGE_TYPE = 68; + static constexpr uint8_t ESTIMATED_SIZE = 12; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_device_request"; } #endif @@ -1934,8 +1934,8 @@ class BluetoothDeviceRequest : public ProtoMessage { }; class BluetoothDeviceConnectionResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 69; - static constexpr uint16_t ESTIMATED_SIZE = 14; + static constexpr uint8_t MESSAGE_TYPE = 69; + static constexpr uint8_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_device_connection_response"; } #endif @@ -1954,8 +1954,8 @@ class BluetoothDeviceConnectionResponse : public ProtoMessage { }; class BluetoothGATTGetServicesRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 70; - static constexpr uint16_t ESTIMATED_SIZE = 4; + static constexpr uint8_t MESSAGE_TYPE = 70; + static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_get_services_request"; } #endif @@ -2015,8 +2015,8 @@ class BluetoothGATTService : public ProtoMessage { }; class BluetoothGATTGetServicesResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 71; - static constexpr uint16_t ESTIMATED_SIZE = 38; + static constexpr uint8_t MESSAGE_TYPE = 71; + static constexpr uint8_t ESTIMATED_SIZE = 38; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_get_services_response"; } #endif @@ -2034,8 +2034,8 @@ class BluetoothGATTGetServicesResponse : public ProtoMessage { }; class BluetoothGATTGetServicesDoneResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 72; - static constexpr uint16_t ESTIMATED_SIZE = 4; + static constexpr uint8_t MESSAGE_TYPE = 72; + static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_get_services_done_response"; } #endif @@ -2051,8 +2051,8 @@ class BluetoothGATTGetServicesDoneResponse : public ProtoMessage { }; class BluetoothGATTReadRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 73; - static constexpr uint16_t ESTIMATED_SIZE = 8; + static constexpr uint8_t MESSAGE_TYPE = 73; + static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_read_request"; } #endif @@ -2069,8 +2069,8 @@ class BluetoothGATTReadRequest : public ProtoMessage { }; class BluetoothGATTReadResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 74; - static constexpr uint16_t ESTIMATED_SIZE = 17; + static constexpr uint8_t MESSAGE_TYPE = 74; + static constexpr uint8_t ESTIMATED_SIZE = 17; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_read_response"; } #endif @@ -2089,8 +2089,8 @@ class BluetoothGATTReadResponse : public ProtoMessage { }; class BluetoothGATTWriteRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 75; - static constexpr uint16_t ESTIMATED_SIZE = 19; + static constexpr uint8_t MESSAGE_TYPE = 75; + static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_request"; } #endif @@ -2110,8 +2110,8 @@ class BluetoothGATTWriteRequest : public ProtoMessage { }; class BluetoothGATTReadDescriptorRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 76; - static constexpr uint16_t ESTIMATED_SIZE = 8; + static constexpr uint8_t MESSAGE_TYPE = 76; + static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_read_descriptor_request"; } #endif @@ -2128,8 +2128,8 @@ class BluetoothGATTReadDescriptorRequest : public ProtoMessage { }; class BluetoothGATTWriteDescriptorRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 77; - static constexpr uint16_t ESTIMATED_SIZE = 17; + static constexpr uint8_t MESSAGE_TYPE = 77; + static constexpr uint8_t ESTIMATED_SIZE = 17; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_descriptor_request"; } #endif @@ -2148,8 +2148,8 @@ class BluetoothGATTWriteDescriptorRequest : public ProtoMessage { }; class BluetoothGATTNotifyRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 78; - static constexpr uint16_t ESTIMATED_SIZE = 10; + static constexpr uint8_t MESSAGE_TYPE = 78; + static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_notify_request"; } #endif @@ -2167,8 +2167,8 @@ class BluetoothGATTNotifyRequest : public ProtoMessage { }; class BluetoothGATTNotifyDataResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 79; - static constexpr uint16_t ESTIMATED_SIZE = 17; + static constexpr uint8_t MESSAGE_TYPE = 79; + static constexpr uint8_t ESTIMATED_SIZE = 17; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_notify_data_response"; } #endif @@ -2187,8 +2187,8 @@ class BluetoothGATTNotifyDataResponse : public ProtoMessage { }; class SubscribeBluetoothConnectionsFreeRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 80; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 80; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "subscribe_bluetooth_connections_free_request"; } #endif @@ -2200,8 +2200,8 @@ class SubscribeBluetoothConnectionsFreeRequest : public ProtoMessage { }; class BluetoothConnectionsFreeResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 81; - static constexpr uint16_t ESTIMATED_SIZE = 16; + static constexpr uint8_t MESSAGE_TYPE = 81; + static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_connections_free_response"; } #endif @@ -2219,8 +2219,8 @@ class BluetoothConnectionsFreeResponse : public ProtoMessage { }; class BluetoothGATTErrorResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 82; - static constexpr uint16_t ESTIMATED_SIZE = 12; + static constexpr uint8_t MESSAGE_TYPE = 82; + static constexpr uint8_t ESTIMATED_SIZE = 12; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_error_response"; } #endif @@ -2238,8 +2238,8 @@ class BluetoothGATTErrorResponse : public ProtoMessage { }; class BluetoothGATTWriteResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 83; - static constexpr uint16_t ESTIMATED_SIZE = 8; + static constexpr uint8_t MESSAGE_TYPE = 83; + static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_response"; } #endif @@ -2256,8 +2256,8 @@ class BluetoothGATTWriteResponse : public ProtoMessage { }; class BluetoothGATTNotifyResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 84; - static constexpr uint16_t ESTIMATED_SIZE = 8; + static constexpr uint8_t MESSAGE_TYPE = 84; + static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_notify_response"; } #endif @@ -2274,8 +2274,8 @@ class BluetoothGATTNotifyResponse : public ProtoMessage { }; class BluetoothDevicePairingResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 85; - static constexpr uint16_t ESTIMATED_SIZE = 10; + static constexpr uint8_t MESSAGE_TYPE = 85; + static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_device_pairing_response"; } #endif @@ -2293,8 +2293,8 @@ class BluetoothDevicePairingResponse : public ProtoMessage { }; class BluetoothDeviceUnpairingResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 86; - static constexpr uint16_t ESTIMATED_SIZE = 10; + static constexpr uint8_t MESSAGE_TYPE = 86; + static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_device_unpairing_response"; } #endif @@ -2312,8 +2312,8 @@ class BluetoothDeviceUnpairingResponse : public ProtoMessage { }; class UnsubscribeBluetoothLEAdvertisementsRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 87; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 87; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "unsubscribe_bluetooth_le_advertisements_request"; } #endif @@ -2325,8 +2325,8 @@ class UnsubscribeBluetoothLEAdvertisementsRequest : public ProtoMessage { }; class BluetoothDeviceClearCacheResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 88; - static constexpr uint16_t ESTIMATED_SIZE = 10; + static constexpr uint8_t MESSAGE_TYPE = 88; + static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_device_clear_cache_response"; } #endif @@ -2344,8 +2344,8 @@ class BluetoothDeviceClearCacheResponse : public ProtoMessage { }; class BluetoothScannerStateResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 126; - static constexpr uint16_t ESTIMATED_SIZE = 4; + static constexpr uint8_t MESSAGE_TYPE = 126; + static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_scanner_state_response"; } #endif @@ -2362,8 +2362,8 @@ class BluetoothScannerStateResponse : public ProtoMessage { }; class BluetoothScannerSetModeRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 127; - static constexpr uint16_t ESTIMATED_SIZE = 2; + static constexpr uint8_t MESSAGE_TYPE = 127; + static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_scanner_set_mode_request"; } #endif @@ -2381,8 +2381,8 @@ class BluetoothScannerSetModeRequest : public ProtoMessage { #ifdef USE_VOICE_ASSISTANT class SubscribeVoiceAssistantRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 89; - static constexpr uint16_t ESTIMATED_SIZE = 6; + static constexpr uint8_t MESSAGE_TYPE = 89; + static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "subscribe_voice_assistant_request"; } #endif @@ -2414,8 +2414,8 @@ class VoiceAssistantAudioSettings : public ProtoMessage { }; class VoiceAssistantRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 90; - static constexpr uint16_t ESTIMATED_SIZE = 41; + static constexpr uint8_t MESSAGE_TYPE = 90; + static constexpr uint8_t ESTIMATED_SIZE = 41; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "voice_assistant_request"; } #endif @@ -2436,8 +2436,8 @@ class VoiceAssistantRequest : public ProtoMessage { }; class VoiceAssistantResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 91; - static constexpr uint16_t ESTIMATED_SIZE = 6; + static constexpr uint8_t MESSAGE_TYPE = 91; + static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "voice_assistant_response"; } #endif @@ -2467,8 +2467,8 @@ class VoiceAssistantEventData : public ProtoMessage { }; class VoiceAssistantEventResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 92; - static constexpr uint16_t ESTIMATED_SIZE = 36; + static constexpr uint8_t MESSAGE_TYPE = 92; + static constexpr uint8_t ESTIMATED_SIZE = 36; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "voice_assistant_event_response"; } #endif @@ -2486,8 +2486,8 @@ class VoiceAssistantEventResponse : public ProtoMessage { }; class VoiceAssistantAudio : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 106; - static constexpr uint16_t ESTIMATED_SIZE = 11; + static constexpr uint8_t MESSAGE_TYPE = 106; + static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "voice_assistant_audio"; } #endif @@ -2505,8 +2505,8 @@ class VoiceAssistantAudio : public ProtoMessage { }; class VoiceAssistantTimerEventResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 115; - static constexpr uint16_t ESTIMATED_SIZE = 30; + static constexpr uint8_t MESSAGE_TYPE = 115; + static constexpr uint8_t ESTIMATED_SIZE = 30; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "voice_assistant_timer_event_response"; } #endif @@ -2528,8 +2528,8 @@ class VoiceAssistantTimerEventResponse : public ProtoMessage { }; class VoiceAssistantAnnounceRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 119; - static constexpr uint16_t ESTIMATED_SIZE = 29; + static constexpr uint8_t MESSAGE_TYPE = 119; + static constexpr uint8_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "voice_assistant_announce_request"; } #endif @@ -2549,8 +2549,8 @@ class VoiceAssistantAnnounceRequest : public ProtoMessage { }; class VoiceAssistantAnnounceFinished : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 120; - static constexpr uint16_t ESTIMATED_SIZE = 2; + static constexpr uint8_t MESSAGE_TYPE = 120; + static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "voice_assistant_announce_finished"; } #endif @@ -2580,8 +2580,8 @@ class VoiceAssistantWakeWord : public ProtoMessage { }; class VoiceAssistantConfigurationRequest : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 121; - static constexpr uint16_t ESTIMATED_SIZE = 0; + static constexpr uint8_t MESSAGE_TYPE = 121; + static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "voice_assistant_configuration_request"; } #endif @@ -2593,8 +2593,8 @@ class VoiceAssistantConfigurationRequest : public ProtoMessage { }; class VoiceAssistantConfigurationResponse : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 122; - static constexpr uint16_t ESTIMATED_SIZE = 56; + static constexpr uint8_t MESSAGE_TYPE = 122; + static constexpr uint8_t ESTIMATED_SIZE = 56; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "voice_assistant_configuration_response"; } #endif @@ -2613,8 +2613,8 @@ class VoiceAssistantConfigurationResponse : public ProtoMessage { }; class VoiceAssistantSetConfiguration : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 123; - static constexpr uint16_t ESTIMATED_SIZE = 18; + static constexpr uint8_t MESSAGE_TYPE = 123; + static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "voice_assistant_set_configuration"; } #endif @@ -2632,8 +2632,8 @@ class VoiceAssistantSetConfiguration : public ProtoMessage { #ifdef USE_ALARM_CONTROL_PANEL class ListEntitiesAlarmControlPanelResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 94; - static constexpr uint16_t ESTIMATED_SIZE = 57; + static constexpr uint8_t MESSAGE_TYPE = 94; + static constexpr uint8_t ESTIMATED_SIZE = 57; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_alarm_control_panel_response"; } #endif @@ -2653,8 +2653,8 @@ class ListEntitiesAlarmControlPanelResponse : public InfoResponseProtoMessage { }; class AlarmControlPanelStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 95; - static constexpr uint16_t ESTIMATED_SIZE = 11; + static constexpr uint8_t MESSAGE_TYPE = 95; + static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "alarm_control_panel_state_response"; } #endif @@ -2671,8 +2671,8 @@ class AlarmControlPanelStateResponse : public StateResponseProtoMessage { }; class AlarmControlPanelCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 96; - static constexpr uint16_t ESTIMATED_SIZE = 20; + static constexpr uint8_t MESSAGE_TYPE = 96; + static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "alarm_control_panel_command_request"; } #endif @@ -2693,8 +2693,8 @@ class AlarmControlPanelCommandRequest : public CommandProtoMessage { #ifdef USE_TEXT class ListEntitiesTextResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 97; - static constexpr uint16_t ESTIMATED_SIZE = 68; + static constexpr uint8_t MESSAGE_TYPE = 97; + static constexpr uint8_t ESTIMATED_SIZE = 68; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_text_response"; } #endif @@ -2715,8 +2715,8 @@ class ListEntitiesTextResponse : public InfoResponseProtoMessage { }; class TextStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 98; - static constexpr uint16_t ESTIMATED_SIZE = 20; + static constexpr uint8_t MESSAGE_TYPE = 98; + static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "text_state_response"; } #endif @@ -2735,8 +2735,8 @@ class TextStateResponse : public StateResponseProtoMessage { }; class TextCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 99; - static constexpr uint16_t ESTIMATED_SIZE = 18; + static constexpr uint8_t MESSAGE_TYPE = 99; + static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "text_command_request"; } #endif @@ -2756,8 +2756,8 @@ class TextCommandRequest : public CommandProtoMessage { #ifdef USE_DATETIME_DATE class ListEntitiesDateResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 100; - static constexpr uint16_t ESTIMATED_SIZE = 49; + static constexpr uint8_t MESSAGE_TYPE = 100; + static constexpr uint8_t ESTIMATED_SIZE = 49; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_date_response"; } #endif @@ -2774,8 +2774,8 @@ class ListEntitiesDateResponse : public InfoResponseProtoMessage { }; class DateStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 101; - static constexpr uint16_t ESTIMATED_SIZE = 23; + static constexpr uint8_t MESSAGE_TYPE = 101; + static constexpr uint8_t ESTIMATED_SIZE = 23; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "date_state_response"; } #endif @@ -2795,8 +2795,8 @@ class DateStateResponse : public StateResponseProtoMessage { }; class DateCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 102; - static constexpr uint16_t ESTIMATED_SIZE = 21; + static constexpr uint8_t MESSAGE_TYPE = 102; + static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "date_command_request"; } #endif @@ -2817,8 +2817,8 @@ class DateCommandRequest : public CommandProtoMessage { #ifdef USE_DATETIME_TIME class ListEntitiesTimeResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 103; - static constexpr uint16_t ESTIMATED_SIZE = 49; + static constexpr uint8_t MESSAGE_TYPE = 103; + static constexpr uint8_t ESTIMATED_SIZE = 49; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_time_response"; } #endif @@ -2835,8 +2835,8 @@ class ListEntitiesTimeResponse : public InfoResponseProtoMessage { }; class TimeStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 104; - static constexpr uint16_t ESTIMATED_SIZE = 23; + static constexpr uint8_t MESSAGE_TYPE = 104; + static constexpr uint8_t ESTIMATED_SIZE = 23; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "time_state_response"; } #endif @@ -2856,8 +2856,8 @@ class TimeStateResponse : public StateResponseProtoMessage { }; class TimeCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 105; - static constexpr uint16_t ESTIMATED_SIZE = 21; + static constexpr uint8_t MESSAGE_TYPE = 105; + static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "time_command_request"; } #endif @@ -2878,8 +2878,8 @@ class TimeCommandRequest : public CommandProtoMessage { #ifdef USE_EVENT class ListEntitiesEventResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 107; - static constexpr uint16_t ESTIMATED_SIZE = 76; + static constexpr uint8_t MESSAGE_TYPE = 107; + static constexpr uint8_t ESTIMATED_SIZE = 76; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_event_response"; } #endif @@ -2898,8 +2898,8 @@ class ListEntitiesEventResponse : public InfoResponseProtoMessage { }; class EventResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 108; - static constexpr uint16_t ESTIMATED_SIZE = 18; + static constexpr uint8_t MESSAGE_TYPE = 108; + static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "event_response"; } #endif @@ -2919,8 +2919,8 @@ class EventResponse : public StateResponseProtoMessage { #ifdef USE_VALVE class ListEntitiesValveResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 109; - static constexpr uint16_t ESTIMATED_SIZE = 64; + static constexpr uint8_t MESSAGE_TYPE = 109; + static constexpr uint8_t ESTIMATED_SIZE = 64; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_valve_response"; } #endif @@ -2941,8 +2941,8 @@ class ListEntitiesValveResponse : public InfoResponseProtoMessage { }; class ValveStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 110; - static constexpr uint16_t ESTIMATED_SIZE = 16; + static constexpr uint8_t MESSAGE_TYPE = 110; + static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "valve_state_response"; } #endif @@ -2960,8 +2960,8 @@ class ValveStateResponse : public StateResponseProtoMessage { }; class ValveCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 111; - static constexpr uint16_t ESTIMATED_SIZE = 18; + static constexpr uint8_t MESSAGE_TYPE = 111; + static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "valve_command_request"; } #endif @@ -2982,8 +2982,8 @@ class ValveCommandRequest : public CommandProtoMessage { #ifdef USE_DATETIME_DATETIME class ListEntitiesDateTimeResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 112; - static constexpr uint16_t ESTIMATED_SIZE = 49; + static constexpr uint8_t MESSAGE_TYPE = 112; + static constexpr uint8_t ESTIMATED_SIZE = 49; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_date_time_response"; } #endif @@ -3000,8 +3000,8 @@ class ListEntitiesDateTimeResponse : public InfoResponseProtoMessage { }; class DateTimeStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 113; - static constexpr uint16_t ESTIMATED_SIZE = 16; + static constexpr uint8_t MESSAGE_TYPE = 113; + static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "date_time_state_response"; } #endif @@ -3019,8 +3019,8 @@ class DateTimeStateResponse : public StateResponseProtoMessage { }; class DateTimeCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 114; - static constexpr uint16_t ESTIMATED_SIZE = 14; + static constexpr uint8_t MESSAGE_TYPE = 114; + static constexpr uint8_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "date_time_command_request"; } #endif @@ -3039,8 +3039,8 @@ class DateTimeCommandRequest : public CommandProtoMessage { #ifdef USE_UPDATE class ListEntitiesUpdateResponse : public InfoResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 116; - static constexpr uint16_t ESTIMATED_SIZE = 58; + static constexpr uint8_t MESSAGE_TYPE = 116; + static constexpr uint8_t ESTIMATED_SIZE = 58; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_update_response"; } #endif @@ -3058,8 +3058,8 @@ class ListEntitiesUpdateResponse : public InfoResponseProtoMessage { }; class UpdateStateResponse : public StateResponseProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 117; - static constexpr uint16_t ESTIMATED_SIZE = 65; + static constexpr uint8_t MESSAGE_TYPE = 117; + static constexpr uint8_t ESTIMATED_SIZE = 65; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "update_state_response"; } #endif @@ -3085,8 +3085,8 @@ class UpdateStateResponse : public StateResponseProtoMessage { }; class UpdateCommandRequest : public CommandProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 118; - static constexpr uint16_t ESTIMATED_SIZE = 11; + static constexpr uint8_t MESSAGE_TYPE = 118; + static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "update_command_request"; } #endif diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 764bac2f39..2271ba7dbd 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -363,11 +363,11 @@ class ProtoService { * @return A ProtoWriteBuffer object with the reserved size. */ virtual ProtoWriteBuffer create_buffer(uint32_t reserve_size) = 0; - virtual bool send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) = 0; + virtual bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) = 0; virtual void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) = 0; // Optimized method that pre-allocates buffer based on message size - bool send_message_(const ProtoMessage &msg, uint16_t message_type) { + bool send_message_(const ProtoMessage &msg, uint8_t message_type) { uint32_t msg_size = 0; msg.calculate_size(msg_size); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index df1f3f8caa..c663af0a5f 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -987,13 +987,24 @@ def build_message_type( # Add MESSAGE_TYPE method if this is a service message if message_id is not None: + # Validate that message_id fits in uint8_t + if message_id > 255: + raise ValueError( + f"Message ID {message_id} for {desc.name} exceeds uint8_t maximum (255)" + ) + # Add static constexpr for message type - public_content.append(f"static constexpr uint16_t MESSAGE_TYPE = {message_id};") + public_content.append(f"static constexpr uint8_t MESSAGE_TYPE = {message_id};") # Add estimated size constant estimated_size = calculate_message_estimated_size(desc) + # Validate that estimated_size fits in uint8_t + if estimated_size > 255: + raise ValueError( + f"Estimated size {estimated_size} for {desc.name} exceeds uint8_t maximum (255)" + ) public_content.append( - f"static constexpr uint16_t ESTIMATED_SIZE = {estimated_size};" + f"static constexpr uint8_t ESTIMATED_SIZE = {estimated_size};" ) # Add message_name method inline in header From 536134e2b59bc0584e4ff4d4fa04249d1a54b4ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jul 2025 10:17:41 -1000 Subject: [PATCH 45/51] preen --- esphome/components/api/api_server.cpp | 3 ++- esphome/components/api/list_entities.h | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 0915746381..6a5d273ec1 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -475,7 +475,8 @@ void APIServer::on_shutdown() { if (!c->send_message(DisconnectRequest())) { // If we can't send the disconnect request directly (tx_buffer full), // schedule it at the front of the batch so it will be sent with priority - c->schedule_message_front_(nullptr, &APIConnection::try_send_disconnect_request, DisconnectRequest::MESSAGE_TYPE); + c->schedule_message_front_(nullptr, &APIConnection::try_send_disconnect_request, DisconnectRequest::MESSAGE_TYPE, + DisconnectRequest::ESTIMATED_SIZE); } } } diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index 4c83ca0935..5e6074e008 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -14,7 +14,7 @@ class APIConnection; #define LIST_ENTITIES_HANDLER(entity_type, EntityClass, ResponseType) \ bool ListEntitiesIterator::on_##entity_type(EntityClass *entity) { /* NOLINT(bugprone-macro-parentheses) */ \ return this->client_->schedule_message_(entity, &APIConnection::try_send_##entity_type##_info, \ - ResponseType::MESSAGE_TYPE); \ + ResponseType::MESSAGE_TYPE, ResponseType::ESTIMATED_SIZE); \ } class ListEntitiesIterator : public ComponentIterator { From 9a0d5019e16f05d4fc4e577326c15ecf8314d945 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jul 2025 10:27:59 -1000 Subject: [PATCH 46/51] tweak --- esphome/components/api/api_frame_helper.h | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index ba3705865f..804768dff7 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -28,16 +28,14 @@ struct ReadPacketBuffer { uint16_t data_len; }; -// Packed packet info structure to minimize memory usage +// Packet info structure struct PacketInfo { - uint8_t message_type; // 1 byte (max 255 message types) - uint8_t padding1; // 1 byte (for alignment) uint16_t offset; // 2 bytes (sufficient for packet size ~1460 bytes) uint16_t payload_size; // 2 bytes (up to 65535 bytes) - uint16_t padding2; // 2 bytes (for alignment to 8 bytes) + uint8_t message_type; // 1 byte (max 255 message types) + // Total: 5 bytes, compiler adds 3 bytes padding for alignment (8 bytes total) - PacketInfo(uint8_t type, uint16_t off, uint16_t size) - : message_type(type), padding1(0), offset(off), payload_size(size), padding2(0) {} + PacketInfo(uint8_t type, uint16_t off, uint16_t size) : offset(off), payload_size(size), message_type(type) {} }; enum class APIError : uint16_t { From 42be5d892aedf1ee2960c49b1fc0420255448c2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jul 2025 10:58:05 -1000 Subject: [PATCH 47/51] cleanup --- esphome/components/api/api_connection.cpp | 11 ++++++--- esphome/components/api/api_connection.h | 6 ++--- esphome/components/api/api_frame_helper.cpp | 22 ++++++++++++++--- esphome/components/api/api_frame_helper.h | 27 ++++++++++++++++----- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 49f14c171b..0a6bc7c9cd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -201,7 +201,7 @@ void APIConnection::loop() { #ifdef USE_CAMERA if (this->image_reader_ && this->image_reader_->available() && this->helper_->can_write_without_blocking()) { - uint32_t to_send = std::min((size_t) MAX_PACKET_SIZE, this->image_reader_->available()); + uint32_t to_send = std::min((size_t) MAX_BATCH_PACKET_SIZE, this->image_reader_->available()); bool done = this->image_reader_->available() == to_send; uint32_t msg_size = 0; ProtoSize::add_fixed_field<4>(msg_size, 1, true); @@ -1614,6 +1614,11 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { if (err == APIError::WOULD_BLOCK) return false; if (err != APIError::OK) { + if (err == APIError::MESSAGE_TOO_LARGE) { + // Log error for oversized messages - safe here since we're not in the middle of encoding + ESP_LOGE(TAG, "%s: Message type %u is too large to send (exceeds %u byte limit)", + this->get_client_combined_info().c_str(), message_type, PacketInfo::MAX_PAYLOAD_SIZE); + } on_fatal_error(); if (err == APIError::SOCKET_WRITE_FAILED && errno == ECONNRESET) { ESP_LOGW(TAG, "%s: Connection reset", this->get_client_combined_info().c_str()); @@ -1771,9 +1776,9 @@ void APIConnection::process_batch_() { // Update tracking variables items_processed++; - // After first message, set remaining size to MAX_PACKET_SIZE to avoid fragmentation + // After first message, set remaining size to MAX_BATCH_PACKET_SIZE to avoid fragmentation if (items_processed == 1) { - remaining_size = MAX_PACKET_SIZE; + remaining_size = MAX_BATCH_PACKET_SIZE; } remaining_size -= payload_size; // Calculate where the next message's header padding will start diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 83a8c10e43..fdc2fb3529 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -628,7 +628,7 @@ class APIConnection : public APIServerConnection { // to send in one go. This is the maximum size of a single packet // that can be sent over the network. // This is to avoid fragmentation of the packet. - static constexpr size_t MAX_PACKET_SIZE = 1390; // MTU + static constexpr size_t MAX_BATCH_PACKET_SIZE = 1390; // MTU bool schedule_batch_(); void process_batch_(); @@ -641,7 +641,7 @@ class APIConnection : public APIServerConnection { // Helper to log a proto message from a MessageCreator object void log_proto_message_(EntityBase *entity, const MessageCreator &creator, uint8_t message_type) { this->flags_.log_only_mode = true; - creator(entity, this, MAX_PACKET_SIZE, true, message_type); + creator(entity, this, MAX_BATCH_PACKET_SIZE, true, message_type); this->flags_.log_only_mode = false; } @@ -661,7 +661,7 @@ class APIConnection : public APIServerConnection { if (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0 && this->helper_->can_write_without_blocking()) { // Now actually encode and send - if (creator(entity, this, MAX_PACKET_SIZE, true) && + if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) && this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) { #ifdef HAS_PROTO_MESSAGE_DUMP // Log the message in verbose mode diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 156fd42cb3..89193ed496 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -62,6 +62,8 @@ const char *api_error_to_str(APIError err) { return "BAD_HANDSHAKE_ERROR_BYTE"; } else if (err == APIError::CONNECTION_CLOSED) { return "CONNECTION_CLOSED"; + } else if (err == APIError::MESSAGE_TOO_LARGE) { + return "MESSAGE_TOO_LARGE"; } return "UNKNOWN"; } @@ -616,8 +618,15 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { // Resize to include MAC space (required for Noise encryption) buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_); - PacketInfo packet{type, 0, - static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)}; + uint16_t payload_size = + static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_); + + // Check if message exceeds PacketInfo limits + if (payload_size > PacketInfo::MAX_PAYLOAD_SIZE) { + return APIError::MESSAGE_TOO_LARGE; + } + + PacketInfo packet{type, 0, payload_size}; return write_protobuf_packets(buffer, std::span(&packet, 1)); } @@ -1003,7 +1012,14 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { return APIError::OK; } APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { - PacketInfo packet{type, 0, static_cast(buffer.get_buffer()->size() - frame_header_padding_)}; + uint16_t payload_size = static_cast(buffer.get_buffer()->size() - frame_header_padding_); + + // Check if message exceeds PacketInfo limits + if (payload_size > PacketInfo::MAX_PAYLOAD_SIZE) { + return APIError::MESSAGE_TOO_LARGE; + } + + PacketInfo packet{type, 0, payload_size}; return write_protobuf_packets(buffer, std::span(&packet, 1)); } diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 804768dff7..fedd24ed58 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -28,14 +29,27 @@ struct ReadPacketBuffer { uint16_t data_len; }; -// Packet info structure +// Packet info structure - packed into 4 bytes using bit fields +// Note: While the API protocol supports message sizes up to 65535 (uint16_t), +// we limit payload_size and offset to 4095 (12 bits) for practical reasons: +// 1. Messages larger than 4095 bytes cannot be sent immediately +// 2. They will be buffered, potentially filling up the tx buffer +// 3. Large messages risk network fragmentation issues +// 4. The typical MTU-based batch size (MAX_BATCH_PACKET_SIZE) is 1390 bytes +// This limitation provides a good balance between efficiency and practicality. struct PacketInfo { - uint16_t offset; // 2 bytes (sufficient for packet size ~1460 bytes) - uint16_t payload_size; // 2 bytes (up to 65535 bytes) - uint8_t message_type; // 1 byte (max 255 message types) - // Total: 5 bytes, compiler adds 3 bytes padding for alignment (8 bytes total) + static constexpr uint16_t MAX_OFFSET = 4095; // 12 bits max + static constexpr uint16_t MAX_PAYLOAD_SIZE = 4095; // 12 bits max - PacketInfo(uint8_t type, uint16_t off, uint16_t size) : offset(off), payload_size(size), message_type(type) {} + uint32_t offset : 12; // 12 bits: 0-4095 + uint32_t payload_size : 12; // 12 bits: 0-4095 + uint32_t message_type : 8; // 8 bits: 0-255 + // Total: 32 bits = 4 bytes exactly + + PacketInfo(uint8_t type, uint16_t off, uint16_t size) : offset(off), payload_size(size), message_type(type) { + assert(off <= MAX_OFFSET); + assert(size <= MAX_PAYLOAD_SIZE); + } }; enum class APIError : uint16_t { @@ -62,6 +76,7 @@ enum class APIError : uint16_t { HANDSHAKESTATE_SPLIT_FAILED = 1020, BAD_HANDSHAKE_ERROR_BYTE = 1021, CONNECTION_CLOSED = 1022, + MESSAGE_TOO_LARGE = 1023, }; const char *api_error_to_str(APIError err); From e472a345c984380ec29378940dab7d828cf0786c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jul 2025 11:05:28 -1000 Subject: [PATCH 48/51] tweak --- esphome/components/api/api_frame_helper.h | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index fedd24ed58..55c6b40a60 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -1,5 +1,4 @@ #pragma once -#include #include #include #include @@ -46,10 +45,7 @@ struct PacketInfo { uint32_t message_type : 8; // 8 bits: 0-255 // Total: 32 bits = 4 bytes exactly - PacketInfo(uint8_t type, uint16_t off, uint16_t size) : offset(off), payload_size(size), message_type(type) { - assert(off <= MAX_OFFSET); - assert(size <= MAX_PAYLOAD_SIZE); - } + PacketInfo(uint8_t type, uint16_t off, uint16_t size) : offset(off), payload_size(size), message_type(type) {} }; enum class APIError : uint16_t { From 3ed533d7099bd691eeed476f5f688d92154eb384 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jul 2025 11:37:17 -1000 Subject: [PATCH 49/51] tweak --- esphome/components/api/api_frame_helper.h | 27 ++++++++++++++--------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 55c6b40a60..301fc5bb31 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -30,22 +30,27 @@ struct ReadPacketBuffer { // Packet info structure - packed into 4 bytes using bit fields // Note: While the API protocol supports message sizes up to 65535 (uint16_t), -// we limit payload_size and offset to 4095 (12 bits) for practical reasons: -// 1. Messages larger than 4095 bytes cannot be sent immediately -// 2. They will be buffered, potentially filling up the tx buffer -// 3. Large messages risk network fragmentation issues -// 4. The typical MTU-based batch size (MAX_BATCH_PACKET_SIZE) is 1390 bytes -// This limitation provides a good balance between efficiency and practicality. +// we limit offset to 2047 (11 bits) and payload_size to 8191 (13 bits) for practical reasons: +// 1. Messages larger than 8KB are rare but do occur (e.g., select entities with many options) +// 2. Very large messages may cause memory pressure on constrained devices +// 3. The typical MTU-based batch size (MAX_BATCH_PACKET_SIZE) is 1390 bytes +// +// Why MAX_OFFSET (2047) > MAX_BATCH_PACKET_SIZE (1390): +// When batching, messages are only included if the total batch size stays under +// MAX_BATCH_PACKET_SIZE. However, we need extra headroom in MAX_OFFSET for: +// - Protocol headers and padding for each message in the batch +// - Future protocol extensions that might need additional offset space +// Large messages (> MAX_BATCH_PACKET_SIZE) are sent individually with offset=0. struct PacketInfo { - static constexpr uint16_t MAX_OFFSET = 4095; // 12 bits max - static constexpr uint16_t MAX_PAYLOAD_SIZE = 4095; // 12 bits max + static constexpr uint16_t MAX_OFFSET = 2047; // 11 bits max + static constexpr uint16_t MAX_PAYLOAD_SIZE = 8191; // 13 bits max - uint32_t offset : 12; // 12 bits: 0-4095 - uint32_t payload_size : 12; // 12 bits: 0-4095 uint32_t message_type : 8; // 8 bits: 0-255 + uint32_t offset : 11; // 11 bits: 0-2047 + uint32_t payload_size : 13; // 13 bits: 0-8191 // Total: 32 bits = 4 bytes exactly - PacketInfo(uint8_t type, uint16_t off, uint16_t size) : offset(off), payload_size(size), message_type(type) {} + PacketInfo(uint8_t type, uint16_t off, uint16_t size) : message_type(type), offset(off), payload_size(size) {} }; enum class APIError : uint16_t { From 0350471fa90edcc350f9bd974bd270663355024c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jul 2025 11:46:03 -1000 Subject: [PATCH 50/51] revert --- esphome/components/api/api_connection.cpp | 5 ----- esphome/components/api/api_frame_helper.cpp | 12 ---------- esphome/components/api/api_frame_helper.h | 25 ++++----------------- 3 files changed, 4 insertions(+), 38 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0a6bc7c9cd..3b0b4858a9 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1614,11 +1614,6 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { if (err == APIError::WOULD_BLOCK) return false; if (err != APIError::OK) { - if (err == APIError::MESSAGE_TOO_LARGE) { - // Log error for oversized messages - safe here since we're not in the middle of encoding - ESP_LOGE(TAG, "%s: Message type %u is too large to send (exceeds %u byte limit)", - this->get_client_combined_info().c_str(), message_type, PacketInfo::MAX_PAYLOAD_SIZE); - } on_fatal_error(); if (err == APIError::SOCKET_WRITE_FAILED && errno == ECONNRESET) { ESP_LOGW(TAG, "%s: Connection reset", this->get_client_combined_info().c_str()); diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 89193ed496..d65f5d4c82 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -62,8 +62,6 @@ const char *api_error_to_str(APIError err) { return "BAD_HANDSHAKE_ERROR_BYTE"; } else if (err == APIError::CONNECTION_CLOSED) { return "CONNECTION_CLOSED"; - } else if (err == APIError::MESSAGE_TOO_LARGE) { - return "MESSAGE_TOO_LARGE"; } return "UNKNOWN"; } @@ -621,11 +619,6 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuff uint16_t payload_size = static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_); - // Check if message exceeds PacketInfo limits - if (payload_size > PacketInfo::MAX_PAYLOAD_SIZE) { - return APIError::MESSAGE_TOO_LARGE; - } - PacketInfo packet{type, 0, payload_size}; return write_protobuf_packets(buffer, std::span(&packet, 1)); } @@ -1014,11 +1007,6 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { uint16_t payload_size = static_cast(buffer.get_buffer()->size() - frame_header_padding_); - // Check if message exceeds PacketInfo limits - if (payload_size > PacketInfo::MAX_PAYLOAD_SIZE) { - return APIError::MESSAGE_TOO_LARGE; - } - PacketInfo packet{type, 0, payload_size}; return write_protobuf_packets(buffer, std::span(&packet, 1)); } diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 301fc5bb31..f013733c3c 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -28,27 +28,11 @@ struct ReadPacketBuffer { uint16_t data_len; }; -// Packet info structure - packed into 4 bytes using bit fields -// Note: While the API protocol supports message sizes up to 65535 (uint16_t), -// we limit offset to 2047 (11 bits) and payload_size to 8191 (13 bits) for practical reasons: -// 1. Messages larger than 8KB are rare but do occur (e.g., select entities with many options) -// 2. Very large messages may cause memory pressure on constrained devices -// 3. The typical MTU-based batch size (MAX_BATCH_PACKET_SIZE) is 1390 bytes -// -// Why MAX_OFFSET (2047) > MAX_BATCH_PACKET_SIZE (1390): -// When batching, messages are only included if the total batch size stays under -// MAX_BATCH_PACKET_SIZE. However, we need extra headroom in MAX_OFFSET for: -// - Protocol headers and padding for each message in the batch -// - Future protocol extensions that might need additional offset space -// Large messages (> MAX_BATCH_PACKET_SIZE) are sent individually with offset=0. +// Packet info structure for batching multiple messages struct PacketInfo { - static constexpr uint16_t MAX_OFFSET = 2047; // 11 bits max - static constexpr uint16_t MAX_PAYLOAD_SIZE = 8191; // 13 bits max - - uint32_t message_type : 8; // 8 bits: 0-255 - uint32_t offset : 11; // 11 bits: 0-2047 - uint32_t payload_size : 13; // 13 bits: 0-8191 - // Total: 32 bits = 4 bytes exactly + uint8_t message_type; // Message type (0-255) + uint16_t offset; // Offset in buffer where message starts + uint16_t payload_size; // Size of the message payload PacketInfo(uint8_t type, uint16_t off, uint16_t size) : message_type(type), offset(off), payload_size(size) {} }; @@ -77,7 +61,6 @@ enum class APIError : uint16_t { HANDSHAKESTATE_SPLIT_FAILED = 1020, BAD_HANDSHAKE_ERROR_BYTE = 1021, CONNECTION_CLOSED = 1022, - MESSAGE_TOO_LARGE = 1023, }; const char *api_error_to_str(APIError err); From db68f9571b0ec8fe81bd41bc417e2d0fd0e7cfe2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jul 2025 11:47:09 -1000 Subject: [PATCH 51/51] revert --- esphome/components/api/api_frame_helper.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index f013733c3c..4bcc4acd61 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -28,13 +28,13 @@ struct ReadPacketBuffer { uint16_t data_len; }; -// Packet info structure for batching multiple messages +// Packed packet info structure to minimize memory usage struct PacketInfo { - uint8_t message_type; // Message type (0-255) uint16_t offset; // Offset in buffer where message starts uint16_t payload_size; // Size of the message payload + uint8_t message_type; // Message type (0-255) - PacketInfo(uint8_t type, uint16_t off, uint16_t size) : message_type(type), offset(off), payload_size(size) {} + PacketInfo(uint8_t type, uint16_t off, uint16_t size) : offset(off), payload_size(size), message_type(type) {} }; enum class APIError : uint16_t {