From 7ed7e7ad262853dcd553b36fbc9844212f703d6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Oct 2025 14:46:44 -0500 Subject: [PATCH 01/12] [climate] Replace std::set with FiniteSetMask for trait storage (#11466) --- esphome/components/api/api.proto | 12 +- esphome/components/api/api_connection.cpp | 12 +- esphome/components/api/api_pb2.h | 12 +- esphome/components/bedjet/bedjet_const.h | 3 +- .../bedjet/climate/bedjet_climate.h | 2 +- esphome/components/climate/climate.cpp | 4 +- esphome/components/climate/climate_mode.h | 12 +- esphome/components/climate/climate_traits.h | 103 ++++++++++-------- esphome/components/climate_ir/climate_ir.h | 18 +-- esphome/components/haier/haier_base.cpp | 6 +- esphome/components/haier/haier_base.h | 7 +- esphome/components/haier/hon_climate.cpp | 10 +- esphome/components/heatpumpir/heatpumpir.h | 11 +- esphome/components/midea/air_conditioner.h | 23 ++-- .../thermostat/thermostat_climate.h | 4 + esphome/components/toshiba/toshiba.cpp | 2 +- esphome/components/toshiba/toshiba.h | 6 +- .../components/tuya/climate/tuya_climate.cpp | 14 +-- tests/integration/state_utils.py | 6 + .../test_host_mode_climate_basic_state.py | 34 +++--- 20 files changed, 160 insertions(+), 141 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d202486cfa..fae0f2e75a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -989,7 +989,7 @@ message ListEntitiesClimateResponse { bool supports_current_temperature = 5; // Deprecated: use feature_flags bool supports_two_point_target_temperature = 6; // Deprecated: use feature_flags - repeated ClimateMode supported_modes = 7 [(container_pointer) = "std::set"]; + repeated ClimateMode supported_modes = 7 [(container_pointer_no_template) = "climate::ClimateModeMask"]; float visual_min_temperature = 8; float visual_max_temperature = 9; float visual_target_temperature_step = 10; @@ -998,11 +998,11 @@ message ListEntitiesClimateResponse { // Deprecated in API version 1.5 bool legacy_supports_away = 11 [deprecated=true]; bool supports_action = 12; // Deprecated: use feature_flags - repeated ClimateFanMode supported_fan_modes = 13 [(container_pointer) = "std::set"]; - repeated ClimateSwingMode supported_swing_modes = 14 [(container_pointer) = "std::set"]; - repeated string supported_custom_fan_modes = 15 [(container_pointer) = "std::set"]; - repeated ClimatePreset supported_presets = 16 [(container_pointer) = "std::set"]; - repeated string supported_custom_presets = 17 [(container_pointer) = "std::set"]; + repeated ClimateFanMode supported_fan_modes = 13 [(container_pointer_no_template) = "climate::ClimateFanModeMask"]; + repeated ClimateSwingMode supported_swing_modes = 14 [(container_pointer_no_template) = "climate::ClimateSwingModeMask"]; + repeated string supported_custom_fan_modes = 15 [(container_pointer) = "std::vector"]; + repeated ClimatePreset supported_presets = 16 [(container_pointer_no_template) = "climate::ClimatePresetMask"]; + repeated string supported_custom_presets = 17 [(container_pointer) = "std::vector"]; bool disabled_by_default = 18; string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON"]; EntityCategory entity_category = 20; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f76080253d..382c4acc16 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -669,18 +669,18 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION); // Current feature flags and other supported parameters msg.feature_flags = traits.get_feature_flags(); - msg.supported_modes = &traits.get_supported_modes_for_api_(); + msg.supported_modes = &traits.get_supported_modes(); msg.visual_min_temperature = traits.get_visual_min_temperature(); msg.visual_max_temperature = traits.get_visual_max_temperature(); msg.visual_target_temperature_step = traits.get_visual_target_temperature_step(); msg.visual_current_temperature_step = traits.get_visual_current_temperature_step(); msg.visual_min_humidity = traits.get_visual_min_humidity(); msg.visual_max_humidity = traits.get_visual_max_humidity(); - msg.supported_fan_modes = &traits.get_supported_fan_modes_for_api_(); - msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes_for_api_(); - msg.supported_presets = &traits.get_supported_presets_for_api_(); - msg.supported_custom_presets = &traits.get_supported_custom_presets_for_api_(); - msg.supported_swing_modes = &traits.get_supported_swing_modes_for_api_(); + msg.supported_fan_modes = &traits.get_supported_fan_modes(); + msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes(); + msg.supported_presets = &traits.get_supported_presets(); + msg.supported_custom_presets = &traits.get_supported_custom_presets(); + msg.supported_swing_modes = &traits.get_supported_swing_modes(); return fill_and_encode_entity_info(climate, msg, ListEntitiesClimateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index ed49498176..3e9a10c1f7 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1377,16 +1377,16 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { #endif bool supports_current_temperature{false}; bool supports_two_point_target_temperature{false}; - const std::set *supported_modes{}; + const climate::ClimateModeMask *supported_modes{}; float visual_min_temperature{0.0f}; float visual_max_temperature{0.0f}; float visual_target_temperature_step{0.0f}; bool supports_action{false}; - const std::set *supported_fan_modes{}; - const std::set *supported_swing_modes{}; - const std::set *supported_custom_fan_modes{}; - const std::set *supported_presets{}; - const std::set *supported_custom_presets{}; + const climate::ClimateFanModeMask *supported_fan_modes{}; + const climate::ClimateSwingModeMask *supported_swing_modes{}; + const std::vector *supported_custom_fan_modes{}; + const climate::ClimatePresetMask *supported_presets{}; + const std::vector *supported_custom_presets{}; float visual_current_temperature_step{0.0f}; bool supports_current_humidity{false}; bool supports_target_humidity{false}; diff --git a/esphome/components/bedjet/bedjet_const.h b/esphome/components/bedjet/bedjet_const.h index 7cac1b61ff..0693be1092 100644 --- a/esphome/components/bedjet/bedjet_const.h +++ b/esphome/components/bedjet/bedjet_const.h @@ -99,9 +99,8 @@ enum BedjetCommand : uint8_t { static const uint8_t BEDJET_FAN_SPEED_COUNT = 20; -static const char *const BEDJET_FAN_STEP_NAMES[BEDJET_FAN_SPEED_COUNT] = BEDJET_FAN_STEP_NAMES_; +static constexpr const char *const BEDJET_FAN_STEP_NAMES[BEDJET_FAN_SPEED_COUNT] = BEDJET_FAN_STEP_NAMES_; static const std::string BEDJET_FAN_STEP_NAME_STRINGS[BEDJET_FAN_SPEED_COUNT] = BEDJET_FAN_STEP_NAMES_; -static const std::set BEDJET_FAN_STEP_NAMES_SET BEDJET_FAN_STEP_NAMES_; } // namespace bedjet } // namespace esphome diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h index 963f2e585a..dbbb73aeae 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.h +++ b/esphome/components/bedjet/climate/bedjet_climate.h @@ -43,7 +43,7 @@ class BedJetClimate : public climate::Climate, public BedJetClient, public Polli }); // It would be better if we had a slider for the fan modes. - traits.set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES_SET); + traits.set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES); traits.set_supported_presets({ // If we support NONE, then have to decide what happens if the user switches to it (turn off?) // climate::CLIMATE_PRESET_NONE, diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 19fe241729..944934edbf 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -385,7 +385,7 @@ void Climate::save_state_() { if (!traits.get_supported_custom_fan_modes().empty() && custom_fan_mode.has_value()) { state.uses_custom_fan_mode = true; const auto &supported = traits.get_supported_custom_fan_modes(); - // std::set has consistent order (lexicographic for strings) + // std::vector maintains insertion order size_t i = 0; for (const auto &mode : supported) { if (mode == custom_fan_mode) { @@ -402,7 +402,7 @@ void Climate::save_state_() { if (!traits.get_supported_custom_presets().empty() && custom_preset.has_value()) { state.uses_custom_preset = true; const auto &supported = traits.get_supported_custom_presets(); - // std::set has consistent order (lexicographic for strings) + // std::vector maintains insertion order size_t i = 0; for (const auto &preset : supported) { if (preset == custom_preset) { diff --git a/esphome/components/climate/climate_mode.h b/esphome/components/climate/climate_mode.h index faec5d2537..44423d2f22 100644 --- a/esphome/components/climate/climate_mode.h +++ b/esphome/components/climate/climate_mode.h @@ -7,6 +7,7 @@ namespace esphome { namespace climate { /// Enum for all modes a climate device can be in. +/// NOTE: If adding values, update ClimateModeMask in climate_traits.h to use the new last value enum ClimateMode : uint8_t { /// The climate device is off CLIMATE_MODE_OFF = 0, @@ -24,7 +25,7 @@ enum ClimateMode : uint8_t { * For example, the target temperature can be adjusted based on a schedule, or learned behavior. * The target temperature can't be adjusted when in this mode. */ - CLIMATE_MODE_AUTO = 6 + CLIMATE_MODE_AUTO = 6 // Update ClimateModeMask in climate_traits.h if adding values after this }; /// Enum for the current action of the climate device. Values match those of ClimateMode. @@ -43,6 +44,7 @@ enum ClimateAction : uint8_t { CLIMATE_ACTION_FAN = 6, }; +/// NOTE: If adding values, update ClimateFanModeMask in climate_traits.h to use the new last value enum ClimateFanMode : uint8_t { /// The fan mode is set to On CLIMATE_FAN_ON = 0, @@ -63,10 +65,11 @@ enum ClimateFanMode : uint8_t { /// The fan mode is set to Diffuse CLIMATE_FAN_DIFFUSE = 8, /// The fan mode is set to Quiet - CLIMATE_FAN_QUIET = 9, + CLIMATE_FAN_QUIET = 9, // Update ClimateFanModeMask in climate_traits.h if adding values after this }; /// Enum for all modes a climate swing can be in +/// NOTE: If adding values, update ClimateSwingModeMask in climate_traits.h to use the new last value enum ClimateSwingMode : uint8_t { /// The swing mode is set to Off CLIMATE_SWING_OFF = 0, @@ -75,10 +78,11 @@ enum ClimateSwingMode : uint8_t { /// The fan mode is set to Vertical CLIMATE_SWING_VERTICAL = 2, /// The fan mode is set to Horizontal - CLIMATE_SWING_HORIZONTAL = 3, + CLIMATE_SWING_HORIZONTAL = 3, // Update ClimateSwingModeMask in climate_traits.h if adding values after this }; /// Enum for all preset modes +/// NOTE: If adding values, update ClimatePresetMask in climate_traits.h to use the new last value enum ClimatePreset : uint8_t { /// No preset is active CLIMATE_PRESET_NONE = 0, @@ -95,7 +99,7 @@ enum ClimatePreset : uint8_t { /// Device is prepared for sleep CLIMATE_PRESET_SLEEP = 6, /// Device is reacting to activity (e.g., movement sensors) - CLIMATE_PRESET_ACTIVITY = 7, + CLIMATE_PRESET_ACTIVITY = 7, // Update ClimatePresetMask in climate_traits.h if adding values after this }; enum ClimateFeature : uint32_t { diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 2962a147d7..1161a54f4e 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -1,19 +1,33 @@ #pragma once -#include +#include #include "climate_mode.h" +#include "esphome/core/finite_set_mask.h" #include "esphome/core/helpers.h" namespace esphome { - -#ifdef USE_API -namespace api { -class APIConnection; -} // namespace api -#endif - namespace climate { +// Type aliases for climate enum bitmasks +// These replace std::set to eliminate red-black tree overhead +// For contiguous enums starting at 0, DefaultBitPolicy provides 1:1 mapping (enum value = bit position) +// Bitmask size is automatically calculated from the last enum value +using ClimateModeMask = FiniteSetMask>; +using ClimateFanModeMask = FiniteSetMask>; +using ClimateSwingModeMask = + FiniteSetMask>; +using ClimatePresetMask = FiniteSetMask>; + +// Lightweight linear search for small vectors (1-20 items) +// Avoids std::find template overhead +template inline bool vector_contains(const std::vector &vec, const T &value) { + for (const auto &item : vec) { + if (item == value) + return true; + } + return false; +} + /** This class contains all static data for climate devices. * * All climate devices must support these features: @@ -107,48 +121,60 @@ class ClimateTraits { } } - void set_supported_modes(std::set modes) { this->supported_modes_ = std::move(modes); } + void set_supported_modes(ClimateModeMask modes) { this->supported_modes_ = modes; } void add_supported_mode(ClimateMode mode) { this->supported_modes_.insert(mode); } bool supports_mode(ClimateMode mode) const { return this->supported_modes_.count(mode); } - const std::set &get_supported_modes() const { return this->supported_modes_; } + const ClimateModeMask &get_supported_modes() const { return this->supported_modes_; } - void set_supported_fan_modes(std::set modes) { this->supported_fan_modes_ = std::move(modes); } + void set_supported_fan_modes(ClimateFanModeMask modes) { this->supported_fan_modes_ = modes; } void add_supported_fan_mode(ClimateFanMode mode) { this->supported_fan_modes_.insert(mode); } - void add_supported_custom_fan_mode(const std::string &mode) { this->supported_custom_fan_modes_.insert(mode); } + void add_supported_custom_fan_mode(const std::string &mode) { this->supported_custom_fan_modes_.push_back(mode); } bool supports_fan_mode(ClimateFanMode fan_mode) const { return this->supported_fan_modes_.count(fan_mode); } bool get_supports_fan_modes() const { return !this->supported_fan_modes_.empty() || !this->supported_custom_fan_modes_.empty(); } - const std::set &get_supported_fan_modes() const { return this->supported_fan_modes_; } + const ClimateFanModeMask &get_supported_fan_modes() const { return this->supported_fan_modes_; } - void set_supported_custom_fan_modes(std::set supported_custom_fan_modes) { + void set_supported_custom_fan_modes(std::vector supported_custom_fan_modes) { this->supported_custom_fan_modes_ = std::move(supported_custom_fan_modes); } - const std::set &get_supported_custom_fan_modes() const { return this->supported_custom_fan_modes_; } + void set_supported_custom_fan_modes(std::initializer_list modes) { + this->supported_custom_fan_modes_ = modes; + } + template void set_supported_custom_fan_modes(const char *const (&modes)[N]) { + this->supported_custom_fan_modes_.assign(modes, modes + N); + } + const std::vector &get_supported_custom_fan_modes() const { return this->supported_custom_fan_modes_; } bool supports_custom_fan_mode(const std::string &custom_fan_mode) const { - return this->supported_custom_fan_modes_.count(custom_fan_mode); + return vector_contains(this->supported_custom_fan_modes_, custom_fan_mode); } - void set_supported_presets(std::set presets) { this->supported_presets_ = std::move(presets); } + void set_supported_presets(ClimatePresetMask presets) { this->supported_presets_ = presets; } void add_supported_preset(ClimatePreset preset) { this->supported_presets_.insert(preset); } - void add_supported_custom_preset(const std::string &preset) { this->supported_custom_presets_.insert(preset); } + void add_supported_custom_preset(const std::string &preset) { this->supported_custom_presets_.push_back(preset); } bool supports_preset(ClimatePreset preset) const { return this->supported_presets_.count(preset); } bool get_supports_presets() const { return !this->supported_presets_.empty(); } - const std::set &get_supported_presets() const { return this->supported_presets_; } + const ClimatePresetMask &get_supported_presets() const { return this->supported_presets_; } - void set_supported_custom_presets(std::set supported_custom_presets) { + void set_supported_custom_presets(std::vector supported_custom_presets) { this->supported_custom_presets_ = std::move(supported_custom_presets); } - const std::set &get_supported_custom_presets() const { return this->supported_custom_presets_; } + void set_supported_custom_presets(std::initializer_list presets) { + this->supported_custom_presets_ = presets; + } + template void set_supported_custom_presets(const char *const (&presets)[N]) { + this->supported_custom_presets_.assign(presets, presets + N); + } + const std::vector &get_supported_custom_presets() const { return this->supported_custom_presets_; } bool supports_custom_preset(const std::string &custom_preset) const { - return this->supported_custom_presets_.count(custom_preset); + return vector_contains(this->supported_custom_presets_, custom_preset); } - void set_supported_swing_modes(std::set modes) { this->supported_swing_modes_ = std::move(modes); } + void set_supported_swing_modes(ClimateSwingModeMask modes) { this->supported_swing_modes_ = modes; } void add_supported_swing_mode(ClimateSwingMode mode) { this->supported_swing_modes_.insert(mode); } bool supports_swing_mode(ClimateSwingMode swing_mode) const { return this->supported_swing_modes_.count(swing_mode); } bool get_supports_swing_modes() const { return !this->supported_swing_modes_.empty(); } - const std::set &get_supported_swing_modes() const { return this->supported_swing_modes_; } + const ClimateSwingModeMask &get_supported_swing_modes() const { return this->supported_swing_modes_; } float get_visual_min_temperature() const { return this->visual_min_temperature_; } void set_visual_min_temperature(float visual_min_temperature) { @@ -179,23 +205,6 @@ class ClimateTraits { void set_visual_max_humidity(float visual_max_humidity) { this->visual_max_humidity_ = visual_max_humidity; } protected: -#ifdef USE_API - // The API connection is a friend class to access internal methods - friend class api::APIConnection; - // These methods return references to internal data structures. - // They are used by the API to avoid copying data when encoding messages. - // Warning: Do not use these methods outside of the API connection code. - // They return references to internal data that can be invalidated. - const std::set &get_supported_modes_for_api_() const { return this->supported_modes_; } - const std::set &get_supported_fan_modes_for_api_() const { return this->supported_fan_modes_; } - const std::set &get_supported_custom_fan_modes_for_api_() const { - return this->supported_custom_fan_modes_; - } - const std::set &get_supported_presets_for_api_() const { return this->supported_presets_; } - const std::set &get_supported_custom_presets_for_api_() const { return this->supported_custom_presets_; } - const std::set &get_supported_swing_modes_for_api_() const { return this->supported_swing_modes_; } -#endif - void set_mode_support_(climate::ClimateMode mode, bool supported) { if (supported) { this->supported_modes_.insert(mode); @@ -226,12 +235,12 @@ class ClimateTraits { float visual_min_humidity_{30}; float visual_max_humidity_{99}; - std::set supported_modes_ = {climate::CLIMATE_MODE_OFF}; - std::set supported_fan_modes_; - std::set supported_swing_modes_; - std::set supported_presets_; - std::set supported_custom_fan_modes_; - std::set supported_custom_presets_; + climate::ClimateModeMask supported_modes_{climate::CLIMATE_MODE_OFF}; + climate::ClimateFanModeMask supported_fan_modes_; + climate::ClimateSwingModeMask supported_swing_modes_; + climate::ClimatePresetMask supported_presets_; + std::vector supported_custom_fan_modes_; + std::vector supported_custom_presets_; }; } // namespace climate diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index ea0656121f..62a43f0b2d 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -24,16 +24,18 @@ class ClimateIR : public Component, public remote_base::RemoteTransmittable { public: ClimateIR(float minimum_temperature, float maximum_temperature, float temperature_step = 1.0f, - bool supports_dry = false, bool supports_fan_only = false, std::set fan_modes = {}, - std::set swing_modes = {}, std::set presets = {}) { + bool supports_dry = false, bool supports_fan_only = false, + climate::ClimateFanModeMask fan_modes = climate::ClimateFanModeMask(), + climate::ClimateSwingModeMask swing_modes = climate::ClimateSwingModeMask(), + climate::ClimatePresetMask presets = climate::ClimatePresetMask()) { this->minimum_temperature_ = minimum_temperature; this->maximum_temperature_ = maximum_temperature; this->temperature_step_ = temperature_step; this->supports_dry_ = supports_dry; this->supports_fan_only_ = supports_fan_only; - this->fan_modes_ = std::move(fan_modes); - this->swing_modes_ = std::move(swing_modes); - this->presets_ = std::move(presets); + this->fan_modes_ = fan_modes; + this->swing_modes_ = swing_modes; + this->presets_ = presets; } void setup() override; @@ -60,9 +62,9 @@ class ClimateIR : public Component, bool supports_heat_{true}; bool supports_dry_{false}; bool supports_fan_only_{false}; - std::set fan_modes_ = {}; - std::set swing_modes_ = {}; - std::set presets_ = {}; + climate::ClimateFanModeMask fan_modes_{}; + climate::ClimateSwingModeMask swing_modes_{}; + climate::ClimatePresetMask presets_{}; sensor::Sensor *sensor_{nullptr}; }; diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 5709b8e9b5..cd2673a272 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -171,7 +171,7 @@ void HaierClimateBase::toggle_power() { PendingAction({ActionRequest::TOGGLE_POWER, esphome::optional()}); } -void HaierClimateBase::set_supported_swing_modes(const std::set &modes) { +void HaierClimateBase::set_supported_swing_modes(climate::ClimateSwingModeMask modes) { this->traits_.set_supported_swing_modes(modes); if (!modes.empty()) this->traits_.add_supported_swing_mode(climate::CLIMATE_SWING_OFF); @@ -179,13 +179,13 @@ void HaierClimateBase::set_supported_swing_modes(const std::sethaier_protocol_.set_answer_timeout(timeout); } -void HaierClimateBase::set_supported_modes(const std::set &modes) { +void HaierClimateBase::set_supported_modes(climate::ClimateModeMask modes) { this->traits_.set_supported_modes(modes); this->traits_.add_supported_mode(climate::CLIMATE_MODE_OFF); // Always available this->traits_.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL); // Always available } -void HaierClimateBase::set_supported_presets(const std::set &presets) { +void HaierClimateBase::set_supported_presets(climate::ClimatePresetMask presets) { this->traits_.set_supported_presets(presets); if (!presets.empty()) this->traits_.add_supported_preset(climate::CLIMATE_PRESET_NONE); diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index f0597c49ff..e24217bfd9 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -1,7 +1,6 @@ #pragma once #include -#include #include "esphome/components/climate/climate.h" #include "esphome/components/uart/uart.h" #include "esphome/core/automation.h" @@ -60,9 +59,9 @@ class HaierClimateBase : public esphome::Component, void send_power_off_command(); void toggle_power(); void reset_protocol() { this->reset_protocol_request_ = true; }; - void set_supported_modes(const std::set &modes); - void set_supported_swing_modes(const std::set &modes); - void set_supported_presets(const std::set &presets); + void set_supported_modes(esphome::climate::ClimateModeMask modes); + void set_supported_swing_modes(esphome::climate::ClimateSwingModeMask modes); + void set_supported_presets(esphome::climate::ClimatePresetMask presets); bool valid_connection() const { return this->protocol_phase_ >= ProtocolPhases::IDLE; }; size_t available() noexcept override { return esphome::uart::UARTDevice::available(); }; size_t read_array(uint8_t *data, size_t len) noexcept override { diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 76558f2ebb..23d28bfd47 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -1033,9 +1033,9 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * { // Swing mode ClimateSwingMode old_swing_mode = this->swing_mode; - const std::set &swing_modes = traits_.get_supported_swing_modes(); - bool vertical_swing_supported = swing_modes.find(CLIMATE_SWING_VERTICAL) != swing_modes.end(); - bool horizontal_swing_supported = swing_modes.find(CLIMATE_SWING_HORIZONTAL) != swing_modes.end(); + const auto &swing_modes = traits_.get_supported_swing_modes(); + bool vertical_swing_supported = swing_modes.count(CLIMATE_SWING_VERTICAL); + bool horizontal_swing_supported = swing_modes.count(CLIMATE_SWING_HORIZONTAL); if (horizontal_swing_supported && (packet.control.horizontal_swing_mode == (uint8_t) hon_protocol::HorizontalSwingMode::AUTO)) { if (vertical_swing_supported && @@ -1218,13 +1218,13 @@ void HonClimate::fill_control_messages_queue_() { (uint8_t) hon_protocol::DataParameters::QUIET_MODE, quiet_mode_buf, 2); } - if ((fast_mode_buf[1] != 0xFF) && ((presets.find(climate::ClimatePreset::CLIMATE_PRESET_BOOST) != presets.end()))) { + if ((fast_mode_buf[1] != 0xFF) && presets.count(climate::ClimatePreset::CLIMATE_PRESET_BOOST)) { this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL, (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + (uint8_t) hon_protocol::DataParameters::FAST_MODE, fast_mode_buf, 2); } - if ((away_mode_buf[1] != 0xFF) && ((presets.find(climate::ClimatePreset::CLIMATE_PRESET_AWAY) != presets.end()))) { + if ((away_mode_buf[1] != 0xFF) && presets.count(climate::ClimatePreset::CLIMATE_PRESET_AWAY)) { this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL, (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + (uint8_t) hon_protocol::DataParameters::TEN_DEGREE, diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index 3e14c11861..ed43ffdc83 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -97,12 +97,11 @@ const float TEMP_MAX = 100; // Celsius class HeatpumpIRClimate : public climate_ir::ClimateIR { public: HeatpumpIRClimate() - : climate_ir::ClimateIR( - TEMP_MIN, TEMP_MAX, 1.0f, true, true, - std::set{climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, - climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_AUTO}, - std::set{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, - climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_FAN_AUTO}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} void setup() override; void set_protocol(Protocol protocol) { this->protocol_ = protocol; } void set_horizontal_default(HorizontalDirection horizontal_direction) { diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index e70bd34e71..6c2401efe7 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -19,6 +19,9 @@ using climate::ClimateTraits; using climate::ClimateMode; using climate::ClimateSwingMode; using climate::ClimateFanMode; +using climate::ClimateModeMask; +using climate::ClimateSwingModeMask; +using climate::ClimatePresetMask; class AirConditioner : public ApplianceBase, public climate::Climate { public: @@ -40,20 +43,20 @@ class AirConditioner : public ApplianceBase, void do_power_on() { this->base_.setPowerState(true); } void do_power_off() { this->base_.setPowerState(false); } void do_power_toggle() { this->base_.setPowerState(this->mode == ClimateMode::CLIMATE_MODE_OFF); } - void set_supported_modes(const std::set &modes) { this->supported_modes_ = modes; } - void set_supported_swing_modes(const std::set &modes) { this->supported_swing_modes_ = modes; } - void set_supported_presets(const std::set &presets) { this->supported_presets_ = presets; } - void set_custom_presets(const std::set &presets) { this->supported_custom_presets_ = presets; } - void set_custom_fan_modes(const std::set &modes) { this->supported_custom_fan_modes_ = modes; } + void set_supported_modes(ClimateModeMask modes) { this->supported_modes_ = modes; } + void set_supported_swing_modes(ClimateSwingModeMask modes) { this->supported_swing_modes_ = modes; } + void set_supported_presets(ClimatePresetMask presets) { this->supported_presets_ = presets; } + void set_custom_presets(const std::vector &presets) { this->supported_custom_presets_ = presets; } + void set_custom_fan_modes(const std::vector &modes) { this->supported_custom_fan_modes_ = modes; } protected: void control(const ClimateCall &call) override; ClimateTraits traits() override; - std::set supported_modes_{}; - std::set supported_swing_modes_{}; - std::set supported_presets_{}; - std::set supported_custom_presets_{}; - std::set supported_custom_fan_modes_{}; + ClimateModeMask supported_modes_{}; + ClimateSwingModeMask supported_swing_modes_{}; + ClimatePresetMask supported_presets_{}; + std::vector supported_custom_presets_{}; + std::vector supported_custom_fan_modes_{}; Sensor *outdoor_sensor_{nullptr}; Sensor *humidity_sensor_{nullptr}; Sensor *power_sensor_{nullptr}; diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 363d2b09fc..42adab7751 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -40,6 +40,10 @@ enum OnBootRestoreFrom : uint8_t { }; struct ThermostatClimateTimer { + ThermostatClimateTimer() = default; + ThermostatClimateTimer(bool active, uint32_t time, uint32_t started, std::function func) + : active(active), time(time), started(started), func(std::move(func)) {} + bool active; uint32_t time; uint32_t started; diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 36e5a21ffa..5efa70d6b4 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -405,7 +405,7 @@ void ToshibaClimate::setup() { this->swing_modes_ = this->toshiba_swing_modes_(); // Ensure swing mode is always initialized to a valid value - if (this->swing_modes_.empty() || this->swing_modes_.find(this->swing_mode) == this->swing_modes_.end()) { + if (this->swing_modes_.empty() || !this->swing_modes_.count(this->swing_mode)) { // No swing support for this model or current swing mode not supported, reset to OFF this->swing_mode = climate::CLIMATE_SWING_OFF; } diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index d76833f406..ee1dec5cc9 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -71,10 +71,10 @@ class ToshibaClimate : public climate_ir::ClimateIR { return TOSHIBA_RAS_2819T_TEMP_C_MAX; return TOSHIBA_GENERIC_TEMP_C_MAX; // Default to GENERIC for unknown models } - std::set toshiba_swing_modes_() { + climate::ClimateSwingModeMask toshiba_swing_modes_() { return (this->model_ == MODEL_GENERIC) - ? std::set{} - : std::set{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; + ? climate::ClimateSwingModeMask() + : climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; } void encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, uint8_t nbytes, uint8_t repeat); bool decode_(remote_base::RemoteReceiveData *data, uint8_t *message, uint8_t nbytes); diff --git a/esphome/components/tuya/climate/tuya_climate.cpp b/esphome/components/tuya/climate/tuya_climate.cpp index d3c78104e3..4d8fd4b310 100644 --- a/esphome/components/tuya/climate/tuya_climate.cpp +++ b/esphome/components/tuya/climate/tuya_climate.cpp @@ -312,18 +312,12 @@ climate::ClimateTraits TuyaClimate::traits() { traits.add_supported_preset(climate::CLIMATE_PRESET_NONE); } if (this->swing_vertical_id_.has_value() && this->swing_horizontal_id_.has_value()) { - std::set supported_swing_modes = { - climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, climate::CLIMATE_SWING_VERTICAL, - climate::CLIMATE_SWING_HORIZONTAL}; - traits.set_supported_swing_modes(std::move(supported_swing_modes)); + traits.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, + climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL}); } else if (this->swing_vertical_id_.has_value()) { - std::set supported_swing_modes = {climate::CLIMATE_SWING_OFF, - climate::CLIMATE_SWING_VERTICAL}; - traits.set_supported_swing_modes(std::move(supported_swing_modes)); + traits.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}); } else if (this->swing_horizontal_id_.has_value()) { - std::set supported_swing_modes = {climate::CLIMATE_SWING_OFF, - climate::CLIMATE_SWING_HORIZONTAL}; - traits.set_supported_swing_modes(std::move(supported_swing_modes)); + traits.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL}); } if (fan_speed_id_) { diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 58d6d2790f..6434a41ddf 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -44,6 +44,7 @@ class InitialStateHelper: helper = InitialStateHelper(entities) client.subscribe_states(helper.on_state_wrapper(user_callback)) await helper.wait_for_initial_states() + # Access initial states via helper.initial_states[key] """ def __init__(self, entities: list[EntityInfo]) -> None: @@ -63,6 +64,8 @@ class InitialStateHelper: self._entities_by_id = { (entity.device_id, entity.key): entity for entity in entities } + # Store initial states by key for test access + self.initial_states: dict[int, EntityState] = {} # Log all entities _LOGGER.debug( @@ -127,6 +130,9 @@ class InitialStateHelper: # If this entity is waiting for initial state if entity_id in self._wait_initial_states: + # Store the initial state for test access + self.initial_states[state.key] = state + # Remove from waiting set self._wait_initial_states.discard(entity_id) diff --git a/tests/integration/test_host_mode_climate_basic_state.py b/tests/integration/test_host_mode_climate_basic_state.py index 4697342a99..7d871ed5a8 100644 --- a/tests/integration/test_host_mode_climate_basic_state.py +++ b/tests/integration/test_host_mode_climate_basic_state.py @@ -2,12 +2,11 @@ from __future__ import annotations -import asyncio - import aioesphomeapi -from aioesphomeapi import ClimateAction, ClimateMode, ClimatePreset, EntityState +from aioesphomeapi import ClimateAction, ClimateInfo, ClimateMode, ClimatePreset import pytest +from .state_utils import InitialStateHelper from .types import APIClientConnectedFactory, RunCompiledFunction @@ -18,26 +17,27 @@ async def test_host_mode_climate_basic_state( api_client_connected: APIClientConnectedFactory, ) -> None: """Test basic climate state reporting.""" - loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: - states: dict[int, EntityState] = {} - climate_future: asyncio.Future[EntityState] = loop.create_future() + # Get entities and set up state synchronization + entities, services = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) >= 1, "Expected at least 1 climate entity" - def on_state(state: EntityState) -> None: - states[state.key] = state - if ( - isinstance(state, aioesphomeapi.ClimateState) - and not climate_future.done() - ): - climate_future.set_result(state) - - client.subscribe_states(on_state) + # Subscribe with the wrapper (no-op callback since we just want initial states) + client.subscribe_states(initial_state_helper.on_state_wrapper(lambda _: None)) + # Wait for all initial states to be broadcast try: - climate_state = await asyncio.wait_for(climate_future, timeout=5.0) + await initial_state_helper.wait_for_initial_states() except TimeoutError: - pytest.fail("Climate state not received within 5 seconds") + pytest.fail("Timeout waiting for initial states") + # Get the climate entity and its initial state + test_climate = climate_infos[0] + climate_state = initial_state_helper.initial_states.get(test_climate.key) + + assert climate_state is not None, "Climate initial state not found" assert isinstance(climate_state, aioesphomeapi.ClimateState) assert climate_state.mode == ClimateMode.OFF assert climate_state.action == ClimateAction.OFF From f1bce262ed0f9f0e4eeea306b82ba070dbab59de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Oct 2025 15:48:20 -0500 Subject: [PATCH 02/12] [uart] Optimize UART components to eliminate temporary vector allocations (#11570) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/uart/__init__.py | 2 +- esphome/components/uart/automation.h | 8 ++++++-- esphome/components/uart/button/__init__.py | 2 +- esphome/components/uart/button/uart_button.h | 3 ++- esphome/components/uart/switch/__init__.py | 6 +++--- esphome/components/uart/switch/uart_switch.h | 6 ++++-- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index f8f927d469..eb911ed007 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -446,7 +446,7 @@ async def uart_write_to_code(config, action_id, template_arg, args): templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) cg.add(var.set_data_template(templ)) else: - cg.add(var.set_data_static(data)) + cg.add(var.set_data_static(cg.ArrayInitializer(*data))) return var diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index b6a50ea22d..9c599253de 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -14,8 +14,12 @@ template class UARTWriteAction : public Action, public Pa this->data_func_ = func; this->static_ = false; } - void set_data_static(const std::vector &data) { - this->data_static_ = data; + void set_data_static(std::vector &&data) { + this->data_static_ = std::move(data); + this->static_ = true; + } + void set_data_static(std::initializer_list data) { + this->data_static_ = std::vector(data); this->static_ = true; } diff --git a/esphome/components/uart/button/__init__.py b/esphome/components/uart/button/__init__.py index 5b811de07d..95fe21271d 100644 --- a/esphome/components/uart/button/__init__.py +++ b/esphome/components/uart/button/__init__.py @@ -33,4 +33,4 @@ async def to_code(config): data = config[CONF_DATA] if isinstance(data, bytes): data = [HexInt(x) for x in data] - cg.add(var.set_data(data)) + cg.add(var.set_data(cg.ArrayInitializer(*data))) diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 2d600b199a..8c7d762a05 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -11,7 +11,8 @@ namespace uart { class UARTButton : public button::Button, public UARTDevice, public Component { public: - void set_data(const std::vector &data) { this->data_ = data; } + void set_data(std::vector &&data) { this->data_ = std::move(data); } + void set_data(std::initializer_list data) { this->data_ = std::vector(data); } void dump_config() override; diff --git a/esphome/components/uart/switch/__init__.py b/esphome/components/uart/switch/__init__.py index b25e070461..290bbed5d3 100644 --- a/esphome/components/uart/switch/__init__.py +++ b/esphome/components/uart/switch/__init__.py @@ -44,16 +44,16 @@ async def to_code(config): if data_on := data.get(CONF_TURN_ON): if isinstance(data_on, bytes): data_on = [HexInt(x) for x in data_on] - cg.add(var.set_data_on(data_on)) + cg.add(var.set_data_on(cg.ArrayInitializer(*data_on))) if data_off := data.get(CONF_TURN_OFF): if isinstance(data_off, bytes): data_off = [HexInt(x) for x in data_off] - cg.add(var.set_data_off(data_off)) + cg.add(var.set_data_off(cg.ArrayInitializer(*data_off))) else: data = config[CONF_DATA] if isinstance(data, bytes): data = [HexInt(x) for x in data] - cg.add(var.set_data_on(data)) + cg.add(var.set_data_on(cg.ArrayInitializer(*data))) cg.add(var.set_single_state(True)) if CONF_SEND_EVERY in config: cg.add(var.set_send_every(config[CONF_SEND_EVERY])) diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index 4ef5b6da4b..909307d57e 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -14,8 +14,10 @@ class UARTSwitch : public switch_::Switch, public UARTDevice, public Component { public: void loop() override; - void set_data_on(const std::vector &data) { this->data_on_ = data; } - void set_data_off(const std::vector &data) { this->data_off_ = data; } + void set_data_on(std::vector &&data) { this->data_on_ = std::move(data); } + void set_data_on(std::initializer_list data) { this->data_on_ = std::vector(data); } + void set_data_off(std::vector &&data) { this->data_off_ = std::move(data); } + void set_data_off(std::initializer_list data) { this->data_off_ = std::vector(data); } void set_send_every(uint32_t send_every) { this->send_every_ = send_every; } void set_single_state(bool single) { this->single_state_ = single; } From e46221750048ccd5242d720fe6ce50109a5c7feb Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Tue, 28 Oct 2025 23:18:47 +0100 Subject: [PATCH 03/12] [packages] Tighten package validation (#11584) --- esphome/components/packages/__init__.py | 2 +- .../component_tests/packages/test_packages.py | 46 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index fdc75d995a..04057c07f2 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -102,7 +102,7 @@ CONFIG_SCHEMA = cv.Any( str: PACKAGE_SCHEMA, } ), - cv.ensure_list(PACKAGE_SCHEMA), + [PACKAGE_SCHEMA], ) diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index d66ca58a69..1c4c91aa52 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.components.packages import do_packages_pass +from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv @@ -94,6 +94,50 @@ def test_package_invalid_dict(basic_esphome, basic_wifi): packages_pass(config) +@pytest.mark.parametrize( + "package", + [ + {"package1": "github://esphome/non-existant-repo/file1.yml@main"}, + {"package2": "github://esphome/non-existant-repo/file1.yml"}, + {"package3": "github://esphome/non-existant-repo/other-folder/file1.yml"}, + [ + "github://esphome/non-existant-repo/file1.yml@main", + "github://esphome/non-existant-repo/file1.yml", + "github://esphome/non-existant-repo/other-folder/file1.yml", + ], + ], +) +def test_package_shorthand(package): + CONFIG_SCHEMA(package) + + +@pytest.mark.parametrize( + "package", + [ + # not github + {"package1": "someplace://esphome/non-existant-repo/file1.yml@main"}, + # missing repo + {"package2": "github://esphome/file1.yml"}, + # missing file + {"package3": "github://esphome/non-existant-repo/@main"}, + {"a": "invalid string, not shorthand"}, + "some string", + 3, + False, + {"a": 8}, + ["someplace://esphome/non-existant-repo/file1.yml@main"], + ["github://esphome/file1.yml"], + ["github://esphome/non-existant-repo/@main"], + ["some string"], + [True], + [3], + ], +) +def test_package_invalid(package): + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(package) + + def test_package_include(basic_wifi, basic_esphome): """ Tests the simple case where an independent config present in a package is added to the top-level config as is. From 466d4522bc05a0274f371ed72e5ea15f3147b148 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:17:16 +1300 Subject: [PATCH 04/12] [http_request] Pass trigger variables into on_response/on_error (#11464) --- esphome/components/http_request/__init__.py | 55 ++++++++++--------- .../components/http_request/http_request.h | 53 +++++++++++------- esphome/core/defines.h | 4 ++ tests/components/http_request/common.yaml | 45 --------------- .../components/http_request/http_request.yaml | 14 +++++ 5 files changed, 79 insertions(+), 92 deletions(-) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index e428838c83..f4fa448c5b 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -12,7 +12,6 @@ from esphome.const import ( CONF_ON_ERROR, CONF_ON_RESPONSE, CONF_TIMEOUT, - CONF_TRIGGER_ID, CONF_URL, CONF_WATCHDOG_TIMEOUT, PLATFORM_HOST, @@ -216,16 +215,8 @@ HTTP_REQUEST_ACTION_SCHEMA = cv.Schema( f"{CONF_VERIFY_SSL} has moved to the base component configuration." ), cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean, - cv.Optional(CONF_ON_RESPONSE): automation.validate_automation( - {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(HttpRequestResponseTrigger)} - ), - cv.Optional(CONF_ON_ERROR): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - automation.Trigger.template() - ) - } - ), + cv.Optional(CONF_ON_RESPONSE): automation.validate_automation(single=True), + cv.Optional(CONF_ON_ERROR): automation.validate_automation(single=True), cv.Optional(CONF_MAX_RESPONSE_BUFFER_SIZE, default="1kB"): cv.validate_bytes, } ) @@ -280,7 +271,12 @@ async def http_request_action_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) cg.add(var.set_url(template_)) cg.add(var.set_method(config[CONF_METHOD])) - cg.add(var.set_capture_response(config[CONF_CAPTURE_RESPONSE])) + + capture_response = config[CONF_CAPTURE_RESPONSE] + if capture_response: + cg.add(var.set_capture_response(capture_response)) + cg.add_define("USE_HTTP_REQUEST_RESPONSE") + cg.add(var.set_max_response_buffer_size(config[CONF_MAX_RESPONSE_BUFFER_SIZE])) if CONF_BODY in config: @@ -303,21 +299,26 @@ async def http_request_action_to_code(config, action_id, template_arg, args): for value in config.get(CONF_COLLECT_HEADERS, []): cg.add(var.add_collect_header(value)) - for conf in config.get(CONF_ON_RESPONSE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - cg.add(var.register_response_trigger(trigger)) - await automation.build_automation( - trigger, - [ - (cg.std_shared_ptr.template(HttpContainer), "response"), - (cg.std_string_ref, "body"), - ], - conf, - ) - for conf in config.get(CONF_ON_ERROR, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - cg.add(var.register_error_trigger(trigger)) - await automation.build_automation(trigger, [], conf) + if response_conf := config.get(CONF_ON_RESPONSE): + if capture_response: + await automation.build_automation( + var.get_success_trigger_with_response(), + [ + (cg.std_shared_ptr.template(HttpContainer), "response"), + (cg.std_string_ref, "body"), + *args, + ], + response_conf, + ) + else: + await automation.build_automation( + var.get_success_trigger(), + [(cg.std_shared_ptr.template(HttpContainer), "response"), *args], + response_conf, + ) + + if error_conf := config.get(CONF_ON_ERROR): + await automation.build_automation(var.get_error_trigger(), args, error_conf) return var diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 5010cf47a0..482cd2da44 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -183,7 +183,9 @@ template class HttpRequestSendAction : public Action { TEMPLATABLE_VALUE(std::string, url) TEMPLATABLE_VALUE(const char *, method) TEMPLATABLE_VALUE(std::string, body) +#ifdef USE_HTTP_REQUEST_RESPONSE TEMPLATABLE_VALUE(bool, capture_response) +#endif void add_request_header(const char *key, TemplatableValue value) { this->request_headers_.insert({key, value}); @@ -195,9 +197,14 @@ template class HttpRequestSendAction : public Action { void set_json(std::function json_func) { this->json_func_ = json_func; } - void register_response_trigger(HttpRequestResponseTrigger *trigger) { this->response_triggers_.push_back(trigger); } +#ifdef USE_HTTP_REQUEST_RESPONSE + Trigger, std::string &, Ts...> *get_success_trigger_with_response() const { + return this->success_trigger_with_response_; + } +#endif + Trigger, Ts...> *get_success_trigger() const { return this->success_trigger_; } - void register_error_trigger(Trigger<> *trigger) { this->error_triggers_.push_back(trigger); } + Trigger *get_error_trigger() const { return this->error_trigger_; } void set_max_response_buffer_size(size_t max_response_buffer_size) { this->max_response_buffer_size_ = max_response_buffer_size; @@ -228,17 +235,20 @@ template class HttpRequestSendAction : public Action { auto container = this->parent_->start(this->url_.value(x...), this->method_.value(x...), body, request_headers, this->collect_headers_); + auto captured_args = std::make_tuple(x...); + if (container == nullptr) { - for (auto *trigger : this->error_triggers_) - trigger->trigger(); + std::apply([this](Ts... captured_args_inner) { this->error_trigger_->trigger(captured_args_inner...); }, + captured_args); return; } size_t content_length = container->content_length; size_t max_length = std::min(content_length, this->max_response_buffer_size_); - std::string response_body; +#ifdef USE_HTTP_REQUEST_RESPONSE if (this->capture_response_.value(x...)) { + std::string response_body; RAMAllocator allocator; uint8_t *buf = allocator.allocate(max_length); if (buf != nullptr) { @@ -253,19 +263,17 @@ template class HttpRequestSendAction : public Action { response_body.assign((char *) buf, read_index); allocator.deallocate(buf, max_length); } - } - - if (this->response_triggers_.size() == 1) { - // if there is only one trigger, no need to copy the response body - this->response_triggers_[0]->process(container, response_body); - } else { - for (auto *trigger : this->response_triggers_) { - // with multiple triggers, pass a copy of the response body to each - // one so that modifications made in one trigger are not visible to - // the others - auto response_body_copy = std::string(response_body); - trigger->process(container, response_body_copy); - } + std::apply( + [this, &container, &response_body](Ts... captured_args_inner) { + this->success_trigger_with_response_->trigger(container, response_body, captured_args_inner...); + }, + captured_args); + } else +#endif + { + std::apply([this, &container]( + Ts... captured_args_inner) { this->success_trigger_->trigger(container, captured_args_inner...); }, + captured_args); } container->end(); } @@ -283,8 +291,13 @@ template class HttpRequestSendAction : public Action { std::set collect_headers_{"content-type", "content-length"}; std::map> json_{}; std::function json_func_{nullptr}; - std::vector response_triggers_{}; - std::vector *> error_triggers_{}; +#ifdef USE_HTTP_REQUEST_RESPONSE + Trigger, std::string &, Ts...> *success_trigger_with_response_ = + new Trigger, std::string &, Ts...>(); +#endif + Trigger, Ts...> *success_trigger_ = + new Trigger, Ts...>(); + Trigger *error_trigger_ = new Trigger(); size_t max_response_buffer_size_{SIZE_MAX}; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 97e766455a..868df6e254 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -187,6 +187,7 @@ #define ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 #define USE_ESP32_CAMERA_JPEG_ENCODER +#define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_IMPROV #define USE_ESP32_IMPROV_NEXT_URL @@ -237,6 +238,7 @@ #define USE_CAPTIVE_PORTAL #define USE_ESP8266_PREFERENCES_FLASH #define USE_HTTP_REQUEST_ESP8266_HTTPS +#define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_SOCKET_IMPL_LWIP_TCP @@ -257,6 +259,7 @@ #ifdef USE_RP2040 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) +#define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP @@ -273,6 +276,7 @@ #endif #ifdef USE_HOST +#define USE_HTTP_REQUEST_RESPONSE #define USE_SOCKET_IMPL_BSD_SOCKETS #define USE_SOCKET_SELECT_SUPPORT #endif diff --git a/tests/components/http_request/common.yaml b/tests/components/http_request/common.yaml index 9ff9f9fb67..62d0a7941a 100644 --- a/tests/components/http_request/common.yaml +++ b/tests/components/http_request/common.yaml @@ -4,51 +4,6 @@ wifi: ssid: MySSID password: password1 -esphome: - on_boot: - then: - - http_request.get: - url: https://esphome.io - request_headers: - Content-Type: application/json - collect_headers: - - age - on_error: - logger.log: "Request failed" - on_response: - then: - - logger.log: - format: "Response status: %d, Duration: %lu ms, age: %s" - args: - - response->status_code - - (long) response->duration_ms - - response->get_response_header("age").c_str() - - http_request.post: - url: https://esphome.io - request_headers: - Content-Type: application/json - json: - key: value - - http_request.send: - method: PUT - url: https://esphome.io - request_headers: - Content-Type: application/json - body: "Some data" - -http_request: - useragent: esphome/tagreader - timeout: 10s - verify_ssl: ${verify_ssl} - -script: - - id: does_not_compile - parameters: - api_url: string - then: - - http_request.get: - url: "http://google.com" - ota: - platform: http_request id: http_request_ota diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index ea7f6bf5a7..13ca5ceba0 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -31,6 +31,20 @@ esphome: request_headers: Content-Type: application/json body: "Some data" + - http_request.post: + url: https://esphome.io + request_headers: + Content-Type: application/json + json: + key: value + capture_response: true + on_response: + then: + - logger.log: + format: "Captured response status: %d, Body: %s" + args: + - response->status_code + - body.c_str() http_request: useragent: esphome/tagreader From 78d780105bf9a9a25a5b3f570f178427f4d2c783 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Oct 2025 19:24:37 -0500 Subject: [PATCH 05/12] [ci] Change upper Python version being tested to 3.13 (#11587) --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb04f6bf8d..655e28e3b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,7 +114,7 @@ jobs: matrix: python-version: - "3.11" - - "3.14" + - "3.13" os: - ubuntu-latest - macOS-latest @@ -123,9 +123,9 @@ jobs: # Minimize CI resource usage # by only running the Python version # version used for docker images on Windows and macOS - - python-version: "3.14" + - python-version: "3.13" os: windows-latest - - python-version: "3.14" + - python-version: "3.13" os: macOS-latest runs-on: ${{ matrix.os }} needs: From 249cd7415badfc720894e4dd9a64d0c4625428bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 00:32:41 +0000 Subject: [PATCH 06/12] Bump aioesphomeapi from 42.3.0 to 42.4.0 (#11586) 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 4a64bd39cc..b0d7d62c36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==42.3.0 +aioesphomeapi==42.4.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import From 4f2d54be4edafbe87996c20529b938f8eef8e93b Mon Sep 17 00:00:00 2001 From: Kent Gibson Date: Wed, 29 Oct 2025 08:48:26 +0800 Subject: [PATCH 07/12] template_alarm_control_panel cleanups (#11469) --- .../template_alarm_control_panel.cpp | 68 ++++++++----------- .../template_alarm_control_panel.h | 8 +-- 2 files changed, 33 insertions(+), 43 deletions(-) diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index eac0629480..d1562ee82f 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -80,19 +80,12 @@ void TemplateAlarmControlPanel::dump_config() { } void TemplateAlarmControlPanel::setup() { - switch (this->restore_mode_) { - case ALARM_CONTROL_PANEL_ALWAYS_DISARMED: - this->current_state_ = ACP_STATE_DISARMED; - break; - case ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED: { - uint8_t value; - this->pref_ = global_preferences->make_preference(this->get_preference_hash()); - if (this->pref_.load(&value)) { - this->current_state_ = static_cast(value); - } else { - this->current_state_ = ACP_STATE_DISARMED; - } - break; + this->current_state_ = ACP_STATE_DISARMED; + if (this->restore_mode_ == ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED) { + uint8_t value; + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); + if (this->pref_.load(&value)) { + this->current_state_ = static_cast(value); } } this->desired_state_ = this->current_state_; @@ -119,15 +112,15 @@ void TemplateAlarmControlPanel::loop() { this->publish_state(ACP_STATE_TRIGGERED); return; } - auto future_state = this->current_state_; + auto next_state = this->current_state_; // reset triggered if all clear if (this->current_state_ == ACP_STATE_TRIGGERED && this->trigger_time_ > 0 && (millis() - this->last_update_) > this->trigger_time_) { - future_state = this->desired_state_; + next_state = this->desired_state_; } - bool delayed_sensor_not_ready = false; - bool instant_sensor_not_ready = false; + bool delayed_sensor_faulted = false; + bool instant_sensor_faulted = false; #ifdef USE_BINARY_SENSOR // Test all of the sensors in the list regardless of the alarm panel state @@ -144,7 +137,7 @@ void TemplateAlarmControlPanel::loop() { // Record the sensor state change this->sensor_data_[sensor_info.second.store_index].last_chime_state = sensor_info.first->state; } - // Check for triggered sensors + // Check for faulted sensors if (sensor_info.first->state) { // Sensor triggered? // Skip if auto bypassed if (std::count(this->bypassed_sensor_indicies_.begin(), this->bypassed_sensor_indicies_.end(), @@ -163,42 +156,41 @@ void TemplateAlarmControlPanel::loop() { } switch (sensor_info.second.type) { - case ALARM_SENSOR_TYPE_INSTANT: - instant_sensor_not_ready = true; - break; case ALARM_SENSOR_TYPE_INSTANT_ALWAYS: - instant_sensor_not_ready = true; - future_state = ACP_STATE_TRIGGERED; + next_state = ACP_STATE_TRIGGERED; + [[fallthrough]]; + case ALARM_SENSOR_TYPE_INSTANT: + instant_sensor_faulted = true; break; case ALARM_SENSOR_TYPE_DELAYED_FOLLOWER: // Look to see if we are in the pending state if (this->current_state_ == ACP_STATE_PENDING) { - delayed_sensor_not_ready = true; + delayed_sensor_faulted = true; } else { - instant_sensor_not_ready = true; + instant_sensor_faulted = true; } break; case ALARM_SENSOR_TYPE_DELAYED: default: - delayed_sensor_not_ready = true; + delayed_sensor_faulted = true; } } } - // Update all sensors not ready flag - this->sensors_ready_ = ((!instant_sensor_not_ready) && (!delayed_sensor_not_ready)); + // Update all sensors ready flag + bool sensors_ready = !(instant_sensor_faulted || delayed_sensor_faulted); // Call the ready state change callback if there was a change - if (this->sensors_ready_ != this->sensors_ready_last_) { + if (this->sensors_ready_ != sensors_ready) { + this->sensors_ready_ = sensors_ready; this->ready_callback_.call(); - this->sensors_ready_last_ = this->sensors_ready_; } #endif - if (this->is_state_armed(future_state) && (!this->sensors_ready_)) { + if (this->is_state_armed(next_state) && (!this->sensors_ready_)) { // Instant sensors - if (instant_sensor_not_ready) { + if (instant_sensor_faulted) { this->publish_state(ACP_STATE_TRIGGERED); - } else if (delayed_sensor_not_ready) { + } else if (delayed_sensor_faulted) { // Delayed sensors if ((this->pending_time_ > 0) && (this->current_state_ != ACP_STATE_TRIGGERED)) { this->publish_state(ACP_STATE_PENDING); @@ -206,8 +198,8 @@ void TemplateAlarmControlPanel::loop() { this->publish_state(ACP_STATE_TRIGGERED); } } - } else if (future_state != this->current_state_) { - this->publish_state(future_state); + } else if (next_state != this->current_state_) { + this->publish_state(next_state); } } @@ -234,8 +226,6 @@ uint32_t TemplateAlarmControlPanel::get_supported_features() const { return features; } -bool TemplateAlarmControlPanel::get_requires_code() const { return !this->codes_.empty(); } - void TemplateAlarmControlPanel::arm_(optional code, alarm_control_panel::AlarmControlPanelState state, uint32_t delay) { if (this->current_state_ != ACP_STATE_DISARMED) { @@ -258,9 +248,9 @@ void TemplateAlarmControlPanel::arm_(optional code, alarm_control_p void TemplateAlarmControlPanel::bypass_before_arming() { #ifdef USE_BINARY_SENSOR for (auto sensor_info : this->sensor_map_) { - // Check for sensors left on and set to bypass automatically and remove them from monitoring + // Check for faulted bypass_auto sensors and remove them from monitoring if ((sensor_info.second.flags & BINARY_SENSOR_MODE_BYPASS_AUTO) && (sensor_info.first->state)) { - ESP_LOGW(TAG, "'%s' is left on and will be automatically bypassed", sensor_info.first->get_name().c_str()); + ESP_LOGW(TAG, "'%s' is faulted and will be automatically bypassed", sensor_info.first->get_name().c_str()); this->bypassed_sensor_indicies_.push_back(sensor_info.second.store_index); } } diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index c3b28e8efa..40a79004da 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -56,7 +56,7 @@ class TemplateAlarmControlPanel : public alarm_control_panel::AlarmControlPanel, void setup() override; void loop() override; uint32_t get_supported_features() const override; - bool get_requires_code() const override; + bool get_requires_code() const override { return !this->codes_.empty(); } bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; } bool get_all_sensors_ready() { return this->sensors_ready_; }; void set_restore_mode(TemplateAlarmControlPanelRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } @@ -66,7 +66,8 @@ class TemplateAlarmControlPanel : public alarm_control_panel::AlarmControlPanel, /** Add a binary_sensor to the alarm_panel. * * @param sensor The BinarySensor instance. - * @param ignore_when_home if this should be ignored when armed_home mode + * @param flags The OR of BinarySensorFlags for the sensor. + * @param type The sensor type which determines its triggering behaviour. */ void add_sensor(binary_sensor::BinarySensor *sensor, uint16_t flags = 0, AlarmSensorType type = ALARM_SENSOR_TYPE_DELAYED); @@ -121,7 +122,7 @@ class TemplateAlarmControlPanel : public alarm_control_panel::AlarmControlPanel, protected: void control(const alarm_control_panel::AlarmControlPanelCall &call) override; #ifdef USE_BINARY_SENSOR - // This maps a binary sensor to its type and attribute bits + // This maps a binary sensor to its alarm specific info std::map sensor_map_; // a list of automatically bypassed sensors std::vector bypassed_sensor_indicies_; @@ -147,7 +148,6 @@ class TemplateAlarmControlPanel : public alarm_control_panel::AlarmControlPanel, bool supports_arm_home_ = false; bool supports_arm_night_ = false; bool sensors_ready_ = false; - bool sensors_ready_last_ = false; uint8_t next_store_index_ = 0; // check if the code is valid bool is_code_valid_(optional code); From 25e4aafd7146a43883213eede281193ce75745b8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:28:29 +1300 Subject: [PATCH 08/12] [ci] Fix auto labeller workflow with wrong comment for too-big with labels (#11592) --- .github/workflows/auto-label-pr.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 4e2f086f47..dd1bc29d83 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -416,7 +416,7 @@ jobs: } // Generate review messages - function generateReviewMessages(finalLabels) { + function generateReviewMessages(finalLabels, originalLabelCount) { const messages = []; const prAuthor = context.payload.pull_request.user.login; @@ -430,15 +430,15 @@ jobs: .reduce((sum, file) => sum + (file.deletions || 0), 0); const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); - const tooManyLabels = finalLabels.length > MAX_LABELS; + const tooManyLabels = originalLabelCount > MAX_LABELS; const tooManyChanges = nonTestChanges > TOO_BIG_THRESHOLD; let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; if (tooManyLabels && tooManyChanges) { - message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${finalLabels.length} different components/areas.`; + message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`; } else if (tooManyLabels) { - message += `This PR affects ${finalLabels.length} different components/areas.`; + message += `This PR affects ${originalLabelCount} different components/areas.`; } else { message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`; } @@ -466,8 +466,8 @@ jobs: } // Handle reviews - async function handleReviews(finalLabels) { - const reviewMessages = generateReviewMessages(finalLabels); + async function handleReviews(finalLabels, originalLabelCount) { + const reviewMessages = generateReviewMessages(finalLabels, originalLabelCount); const hasReviewableLabels = finalLabels.some(label => ['too-big', 'needs-codeowners'].includes(label) ); @@ -627,6 +627,7 @@ jobs: // Handle too many labels (only for non-mega PRs) const tooManyLabels = finalLabels.length > MAX_LABELS; + const originalLabelCount = finalLabels.length; if (tooManyLabels && !isMegaPR && !finalLabels.includes('too-big')) { finalLabels = ['too-big']; @@ -635,7 +636,7 @@ jobs: console.log('Computed labels:', finalLabels.join(', ')); // Handle reviews - await handleReviews(finalLabels); + await handleReviews(finalLabels, originalLabelCount); // Apply labels if (finalLabels.length > 0) { From 99f48ae51c79d0159188d679f5b8659be488c7af Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:29:40 +1300 Subject: [PATCH 09/12] [logger] Improve level validation errors (#11589) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/logger/__init__.py | 35 +++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 22bf3d2f4c..cf78e6ae63 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -173,14 +173,34 @@ def uart_selection(value): raise NotImplementedError -def validate_local_no_higher_than_global(value): - global_level = LOG_LEVEL_SEVERITY.index(value[CONF_LEVEL]) - for tag, level in value.get(CONF_LOGS, {}).items(): - if LOG_LEVEL_SEVERITY.index(level) > global_level: - raise cv.Invalid( - f"The configured log level for {tag} ({level}) must be no more severe than the global log level {value[CONF_LEVEL]}." +def validate_local_no_higher_than_global(config): + global_level = config[CONF_LEVEL] + global_level_index = LOG_LEVEL_SEVERITY.index(global_level) + errs = [] + for tag, level in config.get(CONF_LOGS, {}).items(): + if LOG_LEVEL_SEVERITY.index(level) > global_level_index: + errs.append( + cv.Invalid( + f"The configured log level for {tag} ({level}) must not be less severe than the global log level ({global_level})", + [CONF_LOGS, tag], + ) ) - return value + if errs: + raise cv.MultipleInvalid(errs) + return config + + +def validate_initial_no_higher_than_global(config): + if initial_level := config.get(CONF_INITIAL_LEVEL): + global_level = config[CONF_LEVEL] + if LOG_LEVEL_SEVERITY.index(initial_level) > LOG_LEVEL_SEVERITY.index( + global_level + ): + raise cv.Invalid( + f"The initial log level ({initial_level}) must not be less severe than the global log level ({global_level})", + [CONF_INITIAL_LEVEL], + ) + return config Logger = logger_ns.class_("Logger", cg.Component) @@ -263,6 +283,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), validate_local_no_higher_than_global, + validate_initial_no_higher_than_global, ) From 0d805355f5bc9753dc7cfe3e9bcd844424aeb746 Mon Sep 17 00:00:00 2001 From: Anton Sergunov Date: Wed, 29 Oct 2025 07:33:16 +0600 Subject: [PATCH 10/12] Fix the LiberTiny bug with UART pin setup (#11518) --- .../uart/uart_component_libretiny.cpp | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 9c065fe5df..8d1d28fce4 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -46,40 +46,58 @@ uint16_t LibreTinyUARTComponent::get_config() { } void LibreTinyUARTComponent::setup() { - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); - } - int8_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin(); int8_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin(); bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted(); bool rx_inverted = rx_pin_ != nullptr && rx_pin_->is_inverted(); + auto shouldFallbackToSoftwareSerial = [&]() -> bool { + auto hasFlags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { + return pin && pin->get_flags() & mask != gpio::Flags::FLAG_NONE; + }; + if (hasFlags(this->tx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || + hasFlags(this->rx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { +#if LT_ARD_HAS_SOFTSERIAL + ESP_LOGI(TAG, "Pins has flags set. Using Software Serial"); + return true; +#else + ESP_LOGW(TAG, "Pin flags are set but not supported for hardware serial. Ignoring"); +#endif + } + return false; + }; + if (false) return; #if LT_HW_UART0 - else if ((tx_pin == -1 || tx_pin == PIN_SERIAL0_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL0_RX)) { + else if ((tx_pin == -1 || tx_pin == PIN_SERIAL0_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL0_RX) && + !shouldFallbackToSoftwareSerial()) { this->serial_ = &Serial0; this->hardware_idx_ = 0; } #endif #if LT_HW_UART1 - else if ((tx_pin == -1 || tx_pin == PIN_SERIAL1_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL1_RX)) { + else if ((tx_pin == -1 || tx_pin == PIN_SERIAL1_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL1_RX) && + !shouldFallbackToSoftwareSerial()) { this->serial_ = &Serial1; this->hardware_idx_ = 1; } #endif #if LT_HW_UART2 - else if ((tx_pin == -1 || tx_pin == PIN_SERIAL2_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL2_RX)) { + else if ((tx_pin == -1 || tx_pin == PIN_SERIAL2_TX) && (rx_pin == -1 || rx_pin == PIN_SERIAL2_RX) && + !shouldFallbackToSoftwareSerial()) { this->serial_ = &Serial2; this->hardware_idx_ = 2; } #endif else { #if LT_ARD_HAS_SOFTSERIAL + if (this->rx_pin_) { + this->rx_pin_->setup(); + } + if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { + this->tx_pin_->setup(); + } this->serial_ = new SoftwareSerial(rx_pin, tx_pin, rx_inverted || tx_inverted); #else this->serial_ = &Serial; From 5528c3c765f434b061df0a08269886de6e8ba2d6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:37:14 +1000 Subject: [PATCH 11/12] [mipi_rgb] Fix rotation with custom model (#11585) --- esphome/components/mipi/__init__.py | 12 ++++++++ esphome/components/mipi_rgb/display.py | 38 ++++++++++++++------------ esphome/components/mipi_spi/display.py | 15 +--------- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 4dff1af62a..93d1750cd6 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -384,6 +384,18 @@ class DriverChip: transform[CONF_TRANSFORM] = True return transform + def swap_xy_schema(self): + uses_swap = self.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED + + def validator(value): + if value: + raise cv.Invalid("Axis swapping not supported by this model") + return cv.boolean(value) + + if uses_swap: + return {cv.Required(CONF_SWAP_XY): cv.boolean} + return {cv.Optional(CONF_SWAP_XY, default=False): validator} + def add_madctl(self, sequence: list, config: dict): # Add the MADCTL command to the sequence based on the configuration. use_flip = config.get(CONF_USE_AXIS_FLIPS) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 3001d33980..9d6b1fa729 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -46,6 +46,7 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, + CONF_DISABLED, CONF_ENABLE_PIN, CONF_GREEN, CONF_HSYNC_PIN, @@ -117,16 +118,16 @@ def data_pin_set(length): def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] - if transforms := model.transforms: - transform = cv.Schema({cv.Required(x): cv.boolean for x in transforms}) - for x in (CONF_SWAP_XY, CONF_MIRROR_X, CONF_MIRROR_Y): - if x not in transforms: - transform = transform.extend( - {cv.Optional(x): cv.invalid(f"{x} not supported by this model")} - ) - else: - transform = cv.invalid("This model does not support transforms") - + transform = cv.Any( + cv.Schema( + { + cv.Required(CONF_MIRROR_X): cv.boolean, + cv.Required(CONF_MIRROR_Y): cv.boolean, + **model.swap_xy_schema(), + } + ), + cv.one_of(CONF_DISABLED, lower=True), + ) # RPI model does not use an init sequence, indicates with empty list if model.initsequence is None: # Custom model requires an init sequence @@ -135,12 +136,16 @@ def model_schema(config): else: iseqconf = cv.Optional(CONF_INIT_SEQUENCE) uses_spi = CONF_INIT_SEQUENCE in config or len(model.initsequence) != 0 - swap_xy = config.get(CONF_TRANSFORM, {}).get(CONF_SWAP_XY, False) - - # Dimensions are optional if the model has a default width and the swap_xy transform is not overridden - cv_dimensions = ( - cv.Optional if model.get_default(CONF_WIDTH) and not swap_xy else cv.Required + # Dimensions are optional if the model has a default width and the x-y transform is not overridden + transform_config = config.get(CONF_TRANSFORM, {}) + is_swapped = ( + isinstance(transform_config, dict) + and transform_config.get(CONF_SWAP_XY, False) is True ) + cv_dimensions = ( + cv.Optional if model.get_default(CONF_WIDTH) and not is_swapped else cv.Required + ) + pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_18BIT, "16", "18") schema = display.FULL_DISPLAY_SCHEMA.extend( { @@ -157,7 +162,7 @@ def model_schema(config): model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of( *pixel_modes, lower=True ), - model.option(CONF_TRANSFORM, cv.UNDEFINED): transform, + cv.Optional(CONF_TRANSFORM): transform, cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True), model.option(CONF_INVERT_COLORS, False): cv.boolean, model.option(CONF_USE_AXIS_FLIPS, True): cv.boolean, @@ -270,7 +275,6 @@ async def to_code(config): cg.add(var.set_vsync_front_porch(config[CONF_VSYNC_FRONT_PORCH])) cg.add(var.set_pclk_inverted(config[CONF_PCLK_INVERTED])) cg.add(var.set_pclk_frequency(config[CONF_PCLK_FREQUENCY])) - index = 0 dpins = [] if CONF_RED in config[CONF_DATA_PINS]: red_pins = config[CONF_DATA_PINS][CONF_RED] diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 891c8b42ff..50ea826eab 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -131,19 +131,6 @@ def denominator(config): ) from StopIteration -def swap_xy_schema(model): - uses_swap = model.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED - - def validator(value): - if value: - raise cv.Invalid("Axis swapping not supported by this model") - return cv.boolean(value) - - if uses_swap: - return {cv.Required(CONF_SWAP_XY): cv.boolean} - return {cv.Optional(CONF_SWAP_XY, default=False): validator} - - def model_schema(config): model = MODELS[config[CONF_MODEL]] bus_mode = config[CONF_BUS_MODE] @@ -152,7 +139,7 @@ def model_schema(config): { cv.Required(CONF_MIRROR_X): cv.boolean, cv.Required(CONF_MIRROR_Y): cv.boolean, - **swap_xy_schema(model), + **model.swap_xy_schema(), } ), cv.one_of(CONF_DISABLED, lower=True), From a609343cb665bbb2411204e795ace686b70168c8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:06:30 +1300 Subject: [PATCH 12/12] [fan] Remove deprecated `set_speed` function (#11590) --- esphome/components/fan/fan.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index b74187eb4a..3739de29a2 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -60,8 +60,6 @@ class FanCall { this->speed_ = speed; return *this; } - ESPDEPRECATED("set_speed() with string argument is deprecated, use integer argument instead.", "2021.9") - FanCall &set_speed(const char *legacy_speed); optional get_speed() const { return this->speed_; } FanCall &set_direction(FanDirection direction) { this->direction_ = direction;