From 518f08b9097bdb3a980483f1358e309987de7402 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:51:13 -1000 Subject: [PATCH 01/13] [mipi_dsi] Disallow swap_xy (#14124) --- esphome/components/mipi_dsi/display.py | 26 +++++-------------- .../mipi_dsi/fixtures/mipi_dsi.yaml | 7 ++++- .../mipi_dsi/test_mipi_dsi_config.py | 6 +++-- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index c288b33cd2..de3791b3a4 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -87,38 +87,24 @@ COLOR_DEPTHS = { def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] + model.defaults[CONF_SWAP_XY] = cv.UNDEFINED transform = cv.Schema( { cv.Required(CONF_MIRROR_X): cv.boolean, cv.Required(CONF_MIRROR_Y): cv.boolean, + cv.Optional(CONF_SWAP_XY): cv.invalid( + "Axis swapping not supported by DSI displays" + ), } ) - if model.get_default(CONF_SWAP_XY) != cv.UNDEFINED: - transform = transform.extend( - { - cv.Optional(CONF_SWAP_XY): cv.invalid( - "Axis swapping not supported by this model" - ) - } - ) - else: - transform = transform.extend( - { - cv.Required(CONF_SWAP_XY): cv.boolean, - } - ) # CUSTOM model will need to provide a custom init sequence iseqconf = ( cv.Required(CONF_INIT_SEQUENCE) if model.initsequence is None else cv.Optional(CONF_INIT_SEQUENCE) ) - 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 + cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_24BIT, "16", "24") schema = display.FULL_DISPLAY_SCHEMA.extend( { diff --git a/tests/component_tests/mipi_dsi/fixtures/mipi_dsi.yaml b/tests/component_tests/mipi_dsi/fixtures/mipi_dsi.yaml index 7d1fc84121..6de2bd5a77 100644 --- a/tests/component_tests/mipi_dsi/fixtures/mipi_dsi.yaml +++ b/tests/component_tests/mipi_dsi/fixtures/mipi_dsi.yaml @@ -15,8 +15,13 @@ esp_ldo: display: - platform: mipi_dsi + id: p4_nano model: WAVESHARE-P4-NANO-10.1 - + rotation: 90 + - platform: mipi_dsi + id: p4_86 + model: "WAVESHARE-P4-86-PANEL" + rotation: 180 i2c: sda: GPIO7 scl: GPIO8 diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index f8a9af0279..d465a8c81b 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -119,9 +119,11 @@ def test_code_generation( main_cpp = generate_main(component_fixture_path("mipi_dsi.yaml")) assert ( - "mipi_dsi_mipi_dsi_id = new mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);" + "p4_nano = new mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);" in main_cpp ) assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp - assert "mipi_dsi_mipi_dsi_id->set_lane_bit_rate(1500);" in main_cpp + assert "p4_nano->set_lane_bit_rate(1500);" in main_cpp + assert "p4_nano->set_rotation(display::DISPLAY_ROTATION_90_DEGREES);" in main_cpp + assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" in main_cpp # assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp From 6ecb01dedc00595aa2565bcf302cde3d7ad1d4e9 Mon Sep 17 00:00:00 2001 From: Sxt Fov <140247217+sxtfov@users.noreply.github.com> Date: Sat, 21 Feb 2026 14:45:15 +0100 Subject: [PATCH 02/13] [cc1101] actions to change general and tuner settings (#14141) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/cc1101/__init__.py | 92 +++++++++++++++++++++++++ esphome/components/cc1101/cc1101.h | 78 ++++++++++++++++++++++ tests/components/cc1101/common.yaml | 96 +++++++++++++++++++++++++++ 3 files changed, 266 insertions(+) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index fbdd7010b4..14b92a18a4 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_DATA, CONF_FREQUENCY, CONF_ID, + CONF_VALUE, CONF_WAIT_TIME, ) from esphome.core import ID @@ -333,3 +334,94 @@ async def send_packet_action_to_code(config, action_id, template_arg, args): arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) cg.add(var.set_data_static(arr, len(data))) return var + + +# Setter action definitions: (setter_name, validator, template_type, enum_map) +_SETTER_ACTIONS = [ + ( + "set_frequency", + cv.All(cv.frequency, cv.float_range(min=300.0e6, max=928.0e6)), + float, + None, + ), + ("set_output_power", cv.float_range(min=-30.0, max=11.0), float, None), + ("set_modulation_type", cv.enum(MODULATION, upper=False), Modulation, MODULATION), + ("set_symbol_rate", cv.float_range(min=600, max=500000), float, None), + ( + "set_rx_attenuation", + cv.enum(RX_ATTENUATION, upper=False), + RxAttenuation, + RX_ATTENUATION, + ), + ("set_dc_blocking_filter", cv.boolean, bool, None), + ("set_manchester", cv.boolean, bool, None), + ( + "set_filter_bandwidth", + cv.All(cv.frequency, cv.float_range(min=58000, max=812000)), + float, + None, + ), + ( + "set_fsk_deviation", + cv.All(cv.frequency, cv.float_range(min=1500, max=381000)), + float, + None, + ), + ("set_msk_deviation", cv.int_range(min=1, max=8), cg.uint8, None), + ("set_channel", cv.uint8_t, cg.uint8, None), + ( + "set_channel_spacing", + cv.All(cv.frequency, cv.float_range(min=25000, max=405000)), + float, + None, + ), + ( + "set_if_frequency", + cv.All(cv.frequency, cv.float_range(min=25000, max=788000)), + float, + None, + ), +] + + +def _register_setter_actions(): + for setter_name, validator, templ_type, enum_map in _SETTER_ACTIONS: + class_name = ( + "".join(word.capitalize() for word in setter_name.split("_")) + "Action" + ) + action_cls = ns.class_( + class_name, automation.Action, cg.Parented.template(CC1101Component) + ) + schema = cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(CC1101Component), + cv.Required(CONF_VALUE): cv.templatable(validator), + }, + key=CONF_VALUE, + ) + + async def _setter_action_to_code( + config, + action_id, + template_arg, + args, + _setter=setter_name, + _type=templ_type, + _map=enum_map, + ): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + data = config[CONF_VALUE] + if cg.is_template(data): + templ_ = await cg.templatable(data, args, _type) + cg.add(getattr(var, _setter)(templ_)) + else: + cg.add(getattr(var, _setter)(_map[data] if _map else data)) + return var + + automation.register_action(f"cc1101.{setter_name}", action_cls, schema)( + _setter_action_to_code + ) + + +_register_setter_actions() diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index e55071e7e3..2efd9e082d 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -161,4 +161,82 @@ template class SendPacketAction : public Action, public P size_t data_static_len_{0}; }; +template class SetSymbolRateAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, symbol_rate) + void play(const Ts &...x) override { this->parent_->set_symbol_rate(this->symbol_rate_.value(x...)); } +}; + +template class SetFrequencyAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, frequency) + void play(const Ts &...x) override { this->parent_->set_frequency(this->frequency_.value(x...)); } +}; + +template class SetOutputPowerAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, output_power) + void play(const Ts &...x) override { this->parent_->set_output_power(this->output_power_.value(x...)); } +}; + +template class SetModulationTypeAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(Modulation, modulation_type) + void play(const Ts &...x) override { this->parent_->set_modulation_type(this->modulation_type_.value(x...)); } +}; + +template class SetRxAttenuationAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(RxAttenuation, rx_attenuation) + void play(const Ts &...x) override { this->parent_->set_rx_attenuation(this->rx_attenuation_.value(x...)); } +}; + +template class SetDcBlockingFilterAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, dc_blocking_filter) + void play(const Ts &...x) override { this->parent_->set_dc_blocking_filter(this->dc_blocking_filter_.value(x...)); } +}; + +template class SetManchesterAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, manchester) + void play(const Ts &...x) override { this->parent_->set_manchester(this->manchester_.value(x...)); } +}; + +template class SetFilterBandwidthAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, filter_bandwidth) + void play(const Ts &...x) override { this->parent_->set_filter_bandwidth(this->filter_bandwidth_.value(x...)); } +}; + +template class SetFskDeviationAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, fsk_deviation) + void play(const Ts &...x) override { this->parent_->set_fsk_deviation(this->fsk_deviation_.value(x...)); } +}; + +template class SetMskDeviationAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint8_t, msk_deviation) + void play(const Ts &...x) override { this->parent_->set_msk_deviation(this->msk_deviation_.value(x...)); } +}; + +template class SetChannelAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint8_t, channel) + void play(const Ts &...x) override { this->parent_->set_channel(this->channel_.value(x...)); } +}; + +template class SetChannelSpacingAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, channel_spacing) + void play(const Ts &...x) override { this->parent_->set_channel_spacing(this->channel_spacing_.value(x...)); } +}; + +template class SetIfFrequencyAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, if_frequency) + void play(const Ts &...x) override { this->parent_->set_if_frequency(this->if_frequency_.value(x...)); } +}; + } // namespace esphome::cc1101 diff --git a/tests/components/cc1101/common.yaml b/tests/components/cc1101/common.yaml index 42ec50911f..9784bfce8b 100644 --- a/tests/components/cc1101/common.yaml +++ b/tests/components/cc1101/common.yaml @@ -35,3 +35,99 @@ button: data: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef] - cc1101.send_packet: !lambda |- return {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + + - cc1101.set_frequency: !lambda |- + return 433.91e6; + - cc1101.set_frequency: + value: "433.91MHz" + - cc1101.set_frequency: + value: 433911000 + - cc1101.set_frequency: 433912000 + + - cc1101.set_output_power: !lambda |- + return -29.9; + - cc1101.set_output_power: + value: "-28" + - cc1101.set_output_power: + value: 10 + - cc1101.set_output_power: 11 + + - cc1101.set_modulation_type: !lambda |- + return cc1101::Modulation::MODULATION_2_FSK; + - cc1101.set_modulation_type: + value: "4-FSK" + - cc1101.set_modulation_type: "GFSK" + + - cc1101.set_symbol_rate: !lambda |- + return 6000.0; + - cc1101.set_symbol_rate: + value: "7000.0" + - cc1101.set_symbol_rate: + value: 8000.0 + - cc1101.set_symbol_rate: 9000 + + - cc1101.set_rx_attenuation: !lambda |- + return cc1101::RxAttenuation::RX_ATTENUATION_0DB; + - cc1101.set_rx_attenuation: + value: "6dB" + - cc1101.set_rx_attenuation: "12dB" + + - cc1101.set_dc_blocking_filter: !lambda |- + return false; + - cc1101.set_dc_blocking_filter: + value: true + - cc1101.set_dc_blocking_filter: false + + - cc1101.set_manchester: !lambda |- + return false; + - cc1101.set_manchester: + value: true + - cc1101.set_manchester: false + + - cc1101.set_filter_bandwidth: !lambda |- + return 58e3; + - cc1101.set_filter_bandwidth: + value: "59kHz" + - cc1101.set_filter_bandwidth: + value: 60000 + - cc1101.set_filter_bandwidth: "61kHz" + + - cc1101.set_fsk_deviation: !lambda |- + return 1.5e3; + - cc1101.set_fsk_deviation: + value: "1.6kHz" + - cc1101.set_fsk_deviation: + value: 1700 + - cc1101.set_fsk_deviation: "1.8kHz" + + - cc1101.set_msk_deviation: !lambda |- + return 1; + - cc1101.set_msk_deviation: + value: "2" + - cc1101.set_msk_deviation: + value: 3 + - cc1101.set_msk_deviation: "4" + + - cc1101.set_channel: !lambda |- + return 0; + - cc1101.set_channel: + value: "1" + - cc1101.set_channel: + value: 3 + - cc1101.set_channel: 3 + + - cc1101.set_channel_spacing: !lambda |- + return 25e3; + - cc1101.set_channel_spacing: + value: "26kHz" + - cc1101.set_channel_spacing: + value: 27000 + - cc1101.set_channel_spacing: "28kHz" + + - cc1101.set_if_frequency: !lambda |- + return 25e3; + - cc1101.set_if_frequency: + value: "26kHz" + - cc1101.set_if_frequency: + value: 27000 + - cc1101.set_if_frequency: "28kHz" From 7fb09da7cf228b3a5d2bbdfd60b3e6fb571b0331 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 11:08:13 -0600 Subject: [PATCH 03/13] [dsmr] Add deprecated std::string overload for set_decryption_key (#14180) --- esphome/components/dsmr/dsmr.h | 3 +++ tests/components/dsmr/common.yaml | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index fafcf62b87..dc81ba9b2a 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -64,6 +64,9 @@ class Dsmr : public Component, public uart::UARTDevice { void dump_config() override; void set_decryption_key(const char *decryption_key); + // Remove before 2026.8.0 + ESPDEPRECATED("Pass .c_str() - e.g. set_decryption_key(key.c_str()). Removed in 2026.8.0", "2026.2.0") + void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key(decryption_key.c_str()); } void set_max_telegram_length(size_t length) { this->max_telegram_len_ = length; } void set_request_pin(GPIOPin *request_pin) { this->request_pin_ = request_pin; } void set_request_interval(uint32_t interval) { this->request_interval_ = interval; } diff --git a/tests/components/dsmr/common.yaml b/tests/components/dsmr/common.yaml index 038bf2806b..d11ce37d59 100644 --- a/tests/components/dsmr/common.yaml +++ b/tests/components/dsmr/common.yaml @@ -1,4 +1,13 @@ +esphome: + on_boot: + then: + - lambda: |- + // Test deprecated std::string overload still compiles + std::string key = "00112233445566778899aabbccddeeff"; + id(dsmr_instance).set_decryption_key(key); + dsmr: + id: dsmr_instance decryption_key: 00112233445566778899aabbccddeeff max_telegram_length: 1000 request_pin: ${request_pin} From 416b97311b800e9563e48da0e403ed22e717ff61 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 11:12:35 -0600 Subject: [PATCH 04/13] [mqtt] Remove broken ESP8266 ssl_fingerprints option (#14182) --- esphome/__main__.py | 14 --------- esphome/components/mqtt/__init__.py | 21 -------------- .../components/mqtt/mqtt_backend_esp8266.h | 5 ---- .../components/mqtt/mqtt_backend_libretiny.h | 5 ---- esphome/components/mqtt/mqtt_client.cpp | 7 ----- esphome/components/mqtt/mqtt_client.h | 15 ---------- esphome/const.py | 1 - esphome/mqtt.py | 29 ++----------------- 8 files changed, 2 insertions(+), 95 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index c86b5604e1..488955f503 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -944,12 +944,6 @@ def command_clean_all(args: ArgsProtocol) -> int | None: return 0 -def command_mqtt_fingerprint(args: ArgsProtocol, config: ConfigType) -> int | None: - from esphome import mqtt - - return mqtt.get_fingerprint(config) - - def command_version(args: ArgsProtocol) -> int | None: safe_print(f"Version: {const.__version__}") return 0 @@ -1237,7 +1231,6 @@ POST_CONFIG_ACTIONS = { "run": command_run, "clean": command_clean, "clean-mqtt": command_clean_mqtt, - "mqtt-fingerprint": command_mqtt_fingerprint, "idedata": command_idedata, "rename": command_rename, "discover": command_discover, @@ -1451,13 +1444,6 @@ def parse_args(argv): ) parser_wizard.add_argument("configuration", help="Your YAML configuration file.") - parser_fingerprint = subparsers.add_parser( - "mqtt-fingerprint", help="Get the SSL fingerprint from a MQTT broker." - ) - parser_fingerprint.add_argument( - "configuration", help="Your YAML configuration file(s).", nargs="+" - ) - subparsers.add_parser("version", help="Print the ESPHome version and exit.") parser_clean = subparsers.add_parser( diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index fe153fedfa..44e8836487 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -1,5 +1,3 @@ -import re - from esphome import automation from esphome.automation import Condition import esphome.codegen as cg @@ -46,7 +44,6 @@ from esphome.const import ( CONF_RETAIN, CONF_SHUTDOWN_MESSAGE, CONF_SKIP_CERT_CN_CHECK, - CONF_SSL_FINGERPRINTS, CONF_STATE_TOPIC, CONF_SUBSCRIBE_QOS, CONF_TOPIC, @@ -221,13 +218,6 @@ def validate_config(value): return out -def validate_fingerprint(value): - value = cv.string(value) - if re.match(r"^[0-9a-f]{40}$", value) is None: - raise cv.Invalid("fingerprint must be valid SHA1 hash") - return value - - def _consume_mqtt_sockets(config: ConfigType) -> ConfigType: """Register socket needs for MQTT component.""" # MQTT needs 1 socket for the broker connection @@ -291,9 +281,6 @@ CONFIG_SCHEMA = cv.All( ), validate_message_just_topic, ), - cv.Optional(CONF_SSL_FINGERPRINTS): cv.All( - cv.only_on_esp8266, cv.ensure_list(validate_fingerprint) - ), cv.Optional(CONF_KEEPALIVE, default="15s"): cv.positive_time_period_seconds, cv.Optional( CONF_REBOOT_TIMEOUT, default="15min" @@ -444,14 +431,6 @@ async def to_code(config): if CONF_LEVEL in log_topic: cg.add(var.set_log_level(logger.LOG_LEVELS[log_topic[CONF_LEVEL]])) - if CONF_SSL_FINGERPRINTS in config: - for fingerprint in config[CONF_SSL_FINGERPRINTS]: - arr = [ - cg.RawExpression(f"0x{fingerprint[i : i + 2]}") for i in range(0, 40, 2) - ] - cg.add(var.add_ssl_fingerprint(arr)) - cg.add_build_flag("-DASYNC_TCP_SSL_ENABLED=1") - cg.add(var.set_keep_alive(config[CONF_KEEPALIVE])) cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) diff --git a/esphome/components/mqtt/mqtt_backend_esp8266.h b/esphome/components/mqtt/mqtt_backend_esp8266.h index 470d1e6a8b..0bf5b510a4 100644 --- a/esphome/components/mqtt/mqtt_backend_esp8266.h +++ b/esphome/components/mqtt/mqtt_backend_esp8266.h @@ -21,11 +21,6 @@ class MQTTBackendESP8266 final : public MQTTBackend { } void set_server(network::IPAddress ip, uint16_t port) final { mqtt_client_.setServer(ip, port); } void set_server(const char *host, uint16_t port) final { mqtt_client_.setServer(host, port); } -#if ASYNC_TCP_SSL_ENABLED - void set_secure(bool secure) { mqtt_client.setSecure(secure); } - void add_server_fingerprint(const uint8_t *fingerprint) { mqtt_client.addServerFingerprint(fingerprint); } -#endif - void set_on_connect(std::function &&callback) final { this->mqtt_client_.onConnect(std::move(callback)); } diff --git a/esphome/components/mqtt/mqtt_backend_libretiny.h b/esphome/components/mqtt/mqtt_backend_libretiny.h index 24bf018a90..5fa3406193 100644 --- a/esphome/components/mqtt/mqtt_backend_libretiny.h +++ b/esphome/components/mqtt/mqtt_backend_libretiny.h @@ -21,11 +21,6 @@ class MQTTBackendLibreTiny final : public MQTTBackend { } void set_server(network::IPAddress ip, uint16_t port) final { mqtt_client_.setServer(IPAddress(ip), port); } void set_server(const char *host, uint16_t port) final { mqtt_client_.setServer(host, port); } -#if ASYNC_TCP_SSL_ENABLED - void set_secure(bool secure) { mqtt_client.setSecure(secure); } - void add_server_fingerprint(const uint8_t *fingerprint) { mqtt_client.addServerFingerprint(fingerprint); } -#endif - void set_on_connect(std::function &&callback) final { this->mqtt_client_.onConnect(std::move(callback)); } diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index c433804dd9..1a03c5329e 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -749,13 +749,6 @@ void MQTTClientComponent::set_on_disconnect(mqtt_on_disconnect_callback_t &&call this->on_disconnect_.add(std::move(callback_copy)); } -#if ASYNC_TCP_SSL_ENABLED -void MQTTClientComponent::add_ssl_fingerprint(const std::array &fingerprint) { - this->mqtt_backend_.setSecure(true); - this->mqtt_backend_.addServerFingerprint(fingerprint.data()); -} -#endif - MQTTClientComponent *global_mqtt_client = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) // MQTTMessageTrigger diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 21edd53eda..127e4073b0 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -137,21 +137,6 @@ class MQTTClientComponent : public Component { bool is_discovery_enabled() const; bool is_discovery_ip_enabled() const; -#if ASYNC_TCP_SSL_ENABLED - /** Add a SSL fingerprint to use for TCP SSL connections to the MQTT broker. - * - * To use this feature you first have to globally enable the `ASYNC_TCP_SSL_ENABLED` define flag. - * This function can be called multiple times and any certificate that matches any of the provided fingerprints - * will match. Calling this method will also automatically disable all non-ssl connections. - * - * @warning This is *not* secure and *not* how SSL is usually done. You'll have to add - * a separate fingerprint for every certificate you use. Additionally, the hashing - * algorithm used here due to the constraints of the MCU, SHA1, is known to be insecure. - * - * @param fingerprint The SSL fingerprint as a 20 value long std::array. - */ - void add_ssl_fingerprint(const std::array &fingerprint); -#endif #ifdef USE_ESP32 void set_ca_certificate(const char *cert) { this->mqtt_backend_.set_ca_certificate(cert); } void set_cl_certificate(const char *cert) { this->mqtt_backend_.set_cl_certificate(cert); } diff --git a/esphome/const.py b/esphome/const.py index f72cbc8893..0179aa129e 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -943,7 +943,6 @@ CONF_SPI = "spi" CONF_SPI_ID = "spi_id" CONF_SPIKE_REJECTION = "spike_rejection" CONF_SSID = "ssid" -CONF_SSL_FINGERPRINTS = "ssl_fingerprints" CONF_STARTUP_DELAY = "startup_delay" CONF_STATE = "state" CONF_STATE_CLASS = "state_class" diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 042df12d67..cbf78bd3f6 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -1,6 +1,5 @@ import contextlib from datetime import datetime -import hashlib import json import logging import ssl @@ -22,14 +21,12 @@ from esphome.const import ( CONF_PASSWORD, CONF_PORT, CONF_SKIP_CERT_CN_CHECK, - CONF_SSL_FINGERPRINTS, CONF_TOPIC, CONF_TOPIC_PREFIX, CONF_USERNAME, ) -from esphome.core import CORE, EsphomeError +from esphome.core import EsphomeError from esphome.helpers import get_int_env, get_str_env -from esphome.log import AnsiFore, color from esphome.types import ConfigType from esphome.util import safe_print @@ -102,9 +99,7 @@ def prepare( elif username: client.username_pw_set(username, password) - if config[CONF_MQTT].get(CONF_SSL_FINGERPRINTS) or config[CONF_MQTT].get( - CONF_CERTIFICATE_AUTHORITY - ): + if config[CONF_MQTT].get(CONF_CERTIFICATE_AUTHORITY): context = ssl.create_default_context( cadata=config[CONF_MQTT].get(CONF_CERTIFICATE_AUTHORITY) ) @@ -283,23 +278,3 @@ def clear_topic(config, topic, username=None, password=None, client_id=None): client.publish(msg.topic, None, retain=True) return initialize(config, [topic], on_message, None, username, password, client_id) - - -# From marvinroger/async-mqtt-client -> scripts/get-fingerprint/get-fingerprint.py -def get_fingerprint(config): - addr = str(config[CONF_MQTT][CONF_BROKER]), int(config[CONF_MQTT][CONF_PORT]) - _LOGGER.info("Getting fingerprint from %s:%s", addr[0], addr[1]) - try: - cert_pem = ssl.get_server_certificate(addr) - except OSError as err: - _LOGGER.error("Unable to connect to server: %s", err) - return 1 - cert_der = ssl.PEM_cert_to_DER_cert(cert_pem) - - sha1 = hashlib.sha1(cert_der).hexdigest() - - safe_print(f"SHA1 Fingerprint: {color(AnsiFore.CYAN, sha1)}") - safe_print( - f"Copy the string above into mqtt.ssl_fingerprints section of {CORE.config_path}" - ) - return 0 From 6ff17fbf7c3d4b24ae0e20103c0828823d49b1dc Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 21 Feb 2026 07:17:54 -1000 Subject: [PATCH 05/13] [epaper_spi] Fix color mapping for weact (#14134) --- esphome/components/epaper_spi/epaper_spi.h | 2 +- .../components/epaper_spi/epaper_weact_3c.cpp | 43 ++++++++++++------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index a8c2fe9b56..a743985518 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -76,7 +76,7 @@ class EPaperBase : public Display, static uint8_t color_to_bit(Color color) { // It's always a shade of gray. Map to BLACK or WHITE. // We split the luminance at a suitable point - if ((static_cast(color.r) + color.g + color.b) > 512) { + if ((color.r + color.g + color.b) >= 382) { return 1; } return 0; diff --git a/esphome/components/epaper_spi/epaper_weact_3c.cpp b/esphome/components/epaper_spi/epaper_weact_3c.cpp index bd83105dd7..d4dac7076c 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.cpp +++ b/esphome/components/epaper_spi/epaper_weact_3c.cpp @@ -5,9 +5,24 @@ namespace esphome::epaper_spi { static constexpr const char *const TAG = "epaper_weact_3c"; +enum class BwrState : uint8_t { + BWR_BLACK, + BWR_WHITE, + BWR_RED, +}; + +static BwrState color_to_bwr(Color color) { + if (color.r > color.g + color.b && color.r > 127) { + return BwrState::BWR_RED; + } + if (color.r + color.g + color.b >= 382) { + return BwrState::BWR_WHITE; + } + return BwrState::BWR_BLACK; +} // SSD1680 3-color display notes: // - Buffer uses 1 bit per pixel, 8 pixels per byte -// - Buffer first half (black_offset): Black/White plane (1=black, 0=white) +// - Buffer first half (black_offset): Black/White plane (0=black, 1=white) // - Buffer second half (red_offset): Red plane (1=red, 0=no red) // - Total buffer: width * height / 4 bytes = 2 * (width * height / 8) // - For 128x296: 128*296/4 = 9472 bytes total (4736 per color) @@ -23,20 +38,20 @@ void EPaperWeAct3C::draw_pixel_at(int x, int y, Color color) { // Use luminance threshold for B/W mapping // Split at halfway point (382 = (255*3)/2) - bool is_white = (static_cast(color.r) + color.g + color.b) > 382; + auto bwr = color_to_bwr(color); // Update black/white plane (first half of buffer) - if (is_white) { - // White pixel - clear bit in black plane - this->buffer_[pos] &= ~bit; - } else { - // Black pixel - set bit in black plane + if (bwr == BwrState::BWR_WHITE) { + // White pixel - set bit in black plane this->buffer_[pos] |= bit; + } else { + // Black pixel - clear bit in black plane + this->buffer_[pos] &= ~bit; } // Update red plane (second half of buffer) // Red if red component is dominant (r > g+b) - if (color.r > color.g + color.b) { + if (bwr == BwrState::BWR_RED) { // Red pixel - set bit in red plane this->buffer_[red_offset + pos] |= bit; } else { @@ -53,21 +68,20 @@ void EPaperWeAct3C::fill(Color color) { const size_t half_buffer = this->buffer_length_ / 2u; // Use luminance threshold for B/W mapping - bool is_white = (static_cast(color.r) + color.g + color.b) > 382; - bool is_red = color.r > color.g + color.b; + auto bits = color_to_bwr(color); // Fill both planes - if (is_white) { - // White - both planes = 0x00 + if (bits == BwrState::BWR_BLACK) { + // Black - both planes = 0x00 this->buffer_.fill(0x00); - } else if (is_red) { + } else if (bits == BwrState::BWR_RED) { // Red - black plane = 0x00, red plane = 0xFF for (size_t i = 0; i < half_buffer; i++) this->buffer_[i] = 0x00; for (size_t i = 0; i < half_buffer; i++) this->buffer_[half_buffer + i] = 0xFF; } else { - // Black - black plane = 0xFF, red plane = 0x00 + // White - black plane = 0xFF, red plane = 0x00 for (size_t i = 0; i < half_buffer; i++) this->buffer_[i] = 0xFF; for (size_t i = 0; i < half_buffer; i++) @@ -112,7 +126,6 @@ bool HOT EPaperWeAct3C::transfer_data() { ESP_LOGV(TAG, "transfer_data: buffer_length=%u, half_buffer=%u", buffer_length, half_buffer); // Use a local buffer for SPI transfers - static constexpr size_t MAX_TRANSFER_SIZE = 128; uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; // First, send the RED buffer (0x26 = WRITE_COLOR) From 48ba007c22d7c542e9de8740aacb8c94ac438a10 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 21 Feb 2026 20:16:20 +0100 Subject: [PATCH 06/13] [nrf52] print line number after crash in logs (#14165) --- esphome/__main__.py | 19 ++++++++++--- esphome/components/nrf52/__init__.py | 40 ++++++++++++++++++++++++++++ tests/unit_tests/test_main.py | 6 +++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 488955f503..ffedb90bde 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -431,6 +431,14 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: return 1 _LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate) + process_stacktrace = None + + try: + module = importlib.import_module("esphome.components." + CORE.target_platform) + process_stacktrace = getattr(module, "process_stacktrace") + except AttributeError: + pass + backtrace_state = False ser = serial.Serial() ser.baudrate = baud_rate @@ -472,9 +480,14 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: ) safe_print(parser.parse_line(line, time_str)) - backtrace_state = platformio_api.process_stacktrace( - config, line, backtrace_state=backtrace_state - ) + if process_stacktrace: + backtrace_state = process_stacktrace( + config, line, backtrace_state + ) + else: + backtrace_state = platformio_api.process_stacktrace( + config, line, backtrace_state=backtrace_state + ) except serial.SerialException: _LOGGER.error("Serial port closed!") return 0 diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 95e3670124..a12d1db1ab 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio import logging from pathlib import Path +import re +import subprocess from esphome import pins import esphome.codegen as cg @@ -380,3 +382,41 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> bool: asyncio.run(logger_connect(address)) return True return False + + +def _addr2line(addr2line: str, elf: Path, addr: str) -> str: + try: + result = subprocess.run( + [addr2line, "-e", elf, addr], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip().splitlines()[0] + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Running command failed: %s", err) + return "" + + +def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool: + if "Last crash:" in line: + return True + if backtrace_state: + match = re.search(r"PC=(0x[0-9a-fA-F]+)\s+LR=(0x[0-9a-fA-F]+)", line) + if match: + pc = match.group(1) + lr = match.group(2) + from esphome.analyze_memory.toolchain import find_tool + + addr2line = find_tool("addr2line") + if addr2line is None: + return False + elf = CORE.relative_pioenvs_path(CORE.name, "firmware.elf") + if not elf.exists(): + _LOGGER.warning("%s does not exists", elf) + return False + _LOGGER.error("=== CRASH ===") + _LOGGER.error("PC: %s", _addr2line(addr2line, elf, pc)) + _LOGGER.error("LR: %s", _addr2line(addr2line, elf, lr)) + + return False diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index c9aa446323..cef561c54b 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2951,6 +2951,7 @@ def test_run_miniterm_batches_lines_with_same_timestamp( mock_serial = MockSerial([chunk, MOCK_SERIAL_END]) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, @@ -2989,6 +2990,7 @@ def test_run_miniterm_different_chunks_different_timestamps( mock_serial = MockSerial([chunk1, chunk2, MOCK_SERIAL_END]) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, @@ -3019,6 +3021,7 @@ def test_run_miniterm_handles_split_lines() -> None: mock_serial = MockSerial([chunk1, chunk2, MOCK_SERIAL_END]) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, @@ -3057,6 +3060,7 @@ def test_run_miniterm_backtrace_state_maintained() -> None: mock_serial = MockSerial([backtrace_chunk, MOCK_SERIAL_END]) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, @@ -3122,6 +3126,7 @@ def test_run_miniterm_handles_empty_reads( mock_serial = MockSerial([b"", chunk, b"", MOCK_SERIAL_END]) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, @@ -3194,6 +3199,7 @@ def test_run_miniterm_buffer_limit_prevents_unbounded_growth() -> None: mock_serial = MockSerial([large_data_no_newline, final_line, MOCK_SERIAL_END]) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} config = { CONF_LOGGER: { CONF_BAUD_RATE: 115200, From 9571a979eb7ff1d881a4a833a639ff54c861174e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 13:53:45 -0600 Subject: [PATCH 07/13] [ci] Suggest StringRef instead of std::string_view (#14183) --- script/ci-custom.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/script/ci-custom.py b/script/ci-custom.py index 231f587068..df819e0f04 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -494,6 +494,22 @@ def lint_no_byte_datatype(fname, match): ) +@lint_re_check( + r"(?:std\s*::\s*string_view|#include\s*)" + CPP_RE_EOL, + include=cpp_include, +) +def lint_no_std_string_view(fname, match): + return ( + f"{highlight('std::string_view')} is not allowed in ESPHome. " + f"It pulls in significant STL template machinery that bloats flash on " + f"resource-constrained embedded targets, does not work well with ArduinoJson, " + f"and duplicates functionality already provided by {highlight('StringRef')}.\n" + f"Please use {highlight('StringRef')} from {highlight('esphome/core/string_ref.h')} " + f"for non-owning string references, or {highlight('const char *')} for simple cases.\n" + f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" + ) + + @lint_post_check def lint_constants_usage(): errs = [] From 5a07908dfa9ef499b20e8a53f23e5ea94b9eb682 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 13:54:20 -0600 Subject: [PATCH 08/13] [api] Fix build error when lambda returns StringRef in homeassistant.event data (#14187) --- esphome/components/api/homeassistant_service.h | 2 ++ tests/components/homeassistant/common.yaml | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 2322d96eef..340699e1a6 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -36,6 +36,8 @@ template class TemplatableStringValue : public TemplatableValue() {} diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 9c6cb71b8b..60e3defd49 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -90,6 +90,19 @@ text_sensor: id: ha_hello_world_text2 attribute: some_attribute +event: + - platform: template + name: Test Event + id: test_event + event_types: + - test_event_type + on_event: + - homeassistant.event: + event: esphome.test_event + data: + event_name: !lambda |- + return event_type; + time: - platform: homeassistant on_time: From e521522b388dce7066ade34d96869187e7c47ca6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 13:54:43 -0600 Subject: [PATCH 09/13] [haier] Fix uninitialized HonSettings causing API connection failures (#14188) --- esphome/components/haier/hon_climate.h | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index a567ab1d89..608d5e7f21 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -29,10 +29,10 @@ enum class CleaningState : uint8_t { enum class HonControlMethod { MONITOR_ONLY = 0, SET_GROUP_PARAMETERS, SET_SINGLE_PARAMETER }; struct HonSettings { - hon_protocol::VerticalSwingMode last_vertiacal_swing; - hon_protocol::HorizontalSwingMode last_horizontal_swing; - bool beeper_state; - bool quiet_mode_state; + hon_protocol::VerticalSwingMode last_vertiacal_swing{hon_protocol::VerticalSwingMode::CENTER}; + hon_protocol::HorizontalSwingMode last_horizontal_swing{hon_protocol::HorizontalSwingMode::CENTER}; + bool beeper_state{true}; + bool quiet_mode_state{false}; }; class HonClimate : public HaierClimateBase { @@ -189,7 +189,7 @@ class HonClimate : public HaierClimateBase { int big_data_sensors_{0}; esphome::optional current_vertical_swing_{}; esphome::optional current_horizontal_swing_{}; - HonSettings settings_; + HonSettings settings_{}; ESPPreferenceObject hon_rtc_; SwitchState quiet_mode_state_{SwitchState::OFF}; }; From 6f198adb0c79bcc8e14cdb07cbf484bb23aa1e52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 14:29:50 -0600 Subject: [PATCH 10/13] [scheduler] Reduce lock acquisitions in process_defer_queue_ (#14107) --- esphome/core/scheduler.cpp | 23 +++++++++++ esphome/core/scheduler.h | 79 ++++++++++++++++++-------------------- 2 files changed, 60 insertions(+), 42 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index e82efcc520..b810df7e1c 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -421,6 +421,29 @@ void Scheduler::full_cleanup_removed_items_() { this->to_remove_ = 0; } +#ifndef ESPHOME_THREAD_SINGLE +void Scheduler::compact_defer_queue_locked_() { + // Rare case: new items were added during processing - compact the vector + // This only happens when: + // 1. A deferred callback calls defer() again, or + // 2. Another thread calls defer() while we're processing + // + // Move unprocessed items (added during this loop) to the front for next iteration + // + // SAFETY: Compacted items may include cancelled items (marked for removal via + // cancel_item_locked_() during execution). This is safe because should_skip_item_() + // checks is_item_removed_() before executing, so cancelled items will be skipped + // and recycled on the next loop iteration. + size_t remaining = this->defer_queue_.size() - this->defer_queue_front_; + for (size_t i = 0; i < remaining; i++) { + this->defer_queue_[i] = std::move(this->defer_queue_[this->defer_queue_front_ + i]); + } + // Use erase() instead of resize() to avoid instantiating _M_default_append + // (saves ~156 bytes flash). Erasing from the end is O(1) - no shifting needed. + this->defer_queue_.erase(this->defer_queue_.begin() + remaining, this->defer_queue_.end()); +} +#endif /* not ESPHOME_THREAD_SINGLE */ + void HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index afe11aaca6..041c6f4687 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -387,41 +387,46 @@ class Scheduler { // No lock needed: single consumer (main loop), stale read just means we process less this iteration size_t defer_queue_end = this->defer_queue_.size(); + // Fast path: nothing to process, avoid lock entirely. + // Safe without lock: single consumer (main loop) reads front_, and a stale size() read + // from a concurrent push can only make us see fewer items — they'll be processed next loop. + if (this->defer_queue_front_ >= defer_queue_end) + return; + + // Merge lock acquisitions: instead of separate locks for move-out and recycle (2N+1 total), + // recycle each item after re-acquiring the lock for the next iteration (N+1 total). + // The lock is held across: recycle → loop condition → move-out, then released for execution. + std::unique_ptr item; + + this->lock_.lock(); while (this->defer_queue_front_ < defer_queue_end) { - std::unique_ptr item; - { - LockGuard lock(this->lock_); - // SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_. - // This is intentional and safe because: - // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function - // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_ - // and has_cancelled_timeout_in_container_locked_ in scheduler.h) - // 3. The lock protects concurrent access, but the nullptr remains until cleanup - item = std::move(this->defer_queue_[this->defer_queue_front_]); - this->defer_queue_front_++; - } + // SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_. + // This is intentional and safe because: + // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function + // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_ + // and has_cancelled_timeout_in_container_locked_ in scheduler.h) + // 3. The lock protects concurrent access, but the nullptr remains until cleanup + item = std::move(this->defer_queue_[this->defer_queue_front_]); + this->defer_queue_front_++; + this->lock_.unlock(); // Execute callback without holding lock to prevent deadlocks // if the callback tries to call defer() again if (!this->should_skip_item_(item.get())) { now = this->execute_item_(item.get(), now); } - // Recycle the defer item after execution - { - LockGuard lock(this->lock_); - this->recycle_item_main_loop_(std::move(item)); - } - } - // If we've consumed all items up to the snapshot point, clean up the dead space - // Single consumer (main loop), so no lock needed for this check - if (this->defer_queue_front_ >= defer_queue_end) { - LockGuard lock(this->lock_); - this->cleanup_defer_queue_locked_(); + this->lock_.lock(); + this->recycle_item_main_loop_(std::move(item)); } + // Clean up the queue (lock already held from last recycle or initial acquisition) + this->cleanup_defer_queue_locked_(); + this->lock_.unlock(); } - // Helper to cleanup defer_queue_ after processing + // Helper to cleanup defer_queue_ after processing. + // Keeps the common clear() path inline, outlines the rare compaction to keep + // cold code out of the hot instruction cache lines. // IMPORTANT: Caller must hold the scheduler lock before calling this function. inline void cleanup_defer_queue_locked_() { // Check if new items were added by producers during processing @@ -429,27 +434,17 @@ class Scheduler { // Common case: no new items - clear everything this->defer_queue_.clear(); } else { - // Rare case: new items were added during processing - compact the vector - // This only happens when: - // 1. A deferred callback calls defer() again, or - // 2. Another thread calls defer() while we're processing - // - // Move unprocessed items (added during this loop) to the front for next iteration - // - // SAFETY: Compacted items may include cancelled items (marked for removal via - // cancel_item_locked_() during execution). This is safe because should_skip_item_() - // checks is_item_removed_() before executing, so cancelled items will be skipped - // and recycled on the next loop iteration. - size_t remaining = this->defer_queue_.size() - this->defer_queue_front_; - for (size_t i = 0; i < remaining; i++) { - this->defer_queue_[i] = std::move(this->defer_queue_[this->defer_queue_front_ + i]); - } - // Use erase() instead of resize() to avoid instantiating _M_default_append - // (saves ~156 bytes flash). Erasing from the end is O(1) - no shifting needed. - this->defer_queue_.erase(this->defer_queue_.begin() + remaining, this->defer_queue_.end()); + // Rare case: new items were added during processing - outlined to keep cold code + // out of the hot instruction cache lines + this->compact_defer_queue_locked_(); } this->defer_queue_front_ = 0; } + + // Cold path for compacting defer_queue_ when new items were added during processing. + // IMPORTANT: Caller must hold the scheduler lock before calling this function. + // IMPORTANT: Must not be inlined - rare path, outlined to keep it out of the hot instruction cache lines. + void __attribute__((noinline)) compact_defer_queue_locked_(); #endif /* not ESPHOME_THREAD_SINGLE */ // Helper to check if item is marked for removal (platform-specific) From 462ac2956312f21e506b1e2e13a6960e18d0d813 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 17:29:41 -0600 Subject: [PATCH 11/13] [scheduler] Use relaxed memory ordering for atomic reads under lock (#14140) --- esphome/core/scheduler.cpp | 10 +++++----- esphome/core/scheduler.h | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index b810df7e1c..36b65f6ff7 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -406,7 +406,7 @@ void Scheduler::full_cleanup_removed_items_() { // Compact in-place: move valid items forward, recycle removed ones size_t write = 0; for (size_t read = 0; read < this->items_.size(); ++read) { - if (!is_item_removed_(this->items_[read].get())) { + if (!is_item_removed_locked_(this->items_[read].get())) { if (write != read) { this->items_[write] = std::move(this->items_[read]); } @@ -531,7 +531,7 @@ void HOT Scheduler::call(uint32_t now) { // Multi-threaded platforms without atomics: must take lock to safely read remove flag { LockGuard guard{this->lock_}; - if (is_item_removed_(item.get())) { + if (is_item_removed_locked_(item.get())) { this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; continue; @@ -568,7 +568,7 @@ void HOT Scheduler::call(uint32_t now) { // during the function call and know if we were cancelled. auto executed_item = this->pop_raw_locked_(); - if (executed_item->remove) { + if (this->is_item_removed_locked_(executed_item.get())) { // We were removed/cancelled in the function call, recycle and continue this->to_remove_--; this->recycle_item_main_loop_(std::move(executed_item)); @@ -595,7 +595,7 @@ void HOT Scheduler::call(uint32_t now) { void HOT Scheduler::process_to_add() { LockGuard guard{this->lock_}; for (auto &it : this->to_add_) { - if (is_item_removed_(it.get())) { + if (is_item_removed_locked_(it.get())) { // Recycle cancelled items this->recycle_item_main_loop_(std::move(it)); continue; @@ -628,7 +628,7 @@ size_t HOT Scheduler::cleanup_() { LockGuard guard{this->lock_}; while (!this->items_.empty()) { auto &item = this->items_[0]; - if (!item->remove) + if (!this->is_item_removed_locked_(item.get())) break; this->to_remove_--; this->recycle_item_main_loop_(this->pop_raw_locked_()); diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 041c6f4687..384d76b6b0 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -314,8 +314,8 @@ class Scheduler { // Fixes: https://github.com/esphome/esphome/issues/11940 if (!item) return false; - if (item->component != component || item->type != type || (skip_removed && item->remove) || - (match_retry && !item->is_retry)) { + if (item->component != component || item->type != type || + (skip_removed && this->is_item_removed_locked_(item.get())) || (match_retry && !item->is_retry)) { return false; } // Name type must match @@ -463,6 +463,18 @@ class Scheduler { #endif } + // Helper to check if item is marked for removal when lock is already held. + // Uses relaxed ordering since the mutex provides all necessary synchronization. + // IMPORTANT: Caller must hold the scheduler lock before calling this function. + bool is_item_removed_locked_(SchedulerItem *item) const { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Lock already held - relaxed is sufficient, mutex provides ordering + return item->remove.load(std::memory_order_relaxed); +#else + return item->remove; +#endif + } + // Helper to set item removal flag (platform-specific) // For ESPHOME_THREAD_MULTI_NO_ATOMICS platforms, the caller must hold the scheduler lock before calling this // function. Uses memory_order_release when setting to true (for cancellation synchronization), @@ -519,7 +531,7 @@ class Scheduler { // it will iterate over these nullptr items. This check prevents crashes. if (!item) continue; - if (is_item_removed_(item.get()) && + if (this->is_item_removed_locked_(item.get()) && this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT, match_retry, /* skip_removed= */ false)) { return true; From a4682615234ac61ff7825e2a922807b11e6772fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 19:41:26 -0600 Subject: [PATCH 12/13] [scheduler] De-template and consolidate scheduler helper functions (#14164) --- esphome/core/scheduler.cpp | 14 +++++++++---- esphome/core/scheduler.h | 41 +++++++------------------------------- 2 files changed, 17 insertions(+), 38 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 36b65f6ff7..e4e0751e10 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -119,10 +119,16 @@ uint32_t Scheduler::calculate_interval_offset_(uint32_t delay) { // Remove before 2026.8.0 along with all retry code bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id) { - return has_cancelled_timeout_in_container_locked_(this->items_, component, name_type, static_name, hash_or_id, - /* match_retry= */ true) || - has_cancelled_timeout_in_container_locked_(this->to_add_, component, name_type, static_name, hash_or_id, - /* match_retry= */ true); + for (auto *container : {&this->items_, &this->to_add_}) { + for (auto &item : *container) { + if (item && this->is_item_removed_locked_(item.get()) && + this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT, + /* match_retry= */ true, /* skip_removed= */ false)) { + return true; + } + } + } + return false; } // Common implementation for both timeout and interval diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 384d76b6b0..16b0ded312 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -308,8 +308,8 @@ class Scheduler { SchedulerItem::Type type, bool match_retry, bool skip_removed = true) const { // THREAD SAFETY: Check for nullptr first to prevent LoadProhibited crashes. On multi-threaded // platforms, items can be moved out of defer_queue_ during processing, leaving nullptr entries. - // PR #11305 added nullptr checks in callers (mark_matching_items_removed_locked_() and - // has_cancelled_timeout_in_container_locked_()), but this check provides defense-in-depth: helper + // PR #11305 added nullptr checks in callers (mark_matching_items_removed_locked_()), but this check + // provides defense-in-depth: helper // functions should be safe regardless of caller behavior. // Fixes: https://github.com/esphome/esphome/issues/11940 if (!item) @@ -403,8 +403,7 @@ class Scheduler { // SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_. // This is intentional and safe because: // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function - // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_ - // and has_cancelled_timeout_in_container_locked_ in scheduler.h) + // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_) // 3. The lock protects concurrent access, but the nullptr remains until cleanup item = std::move(this->defer_queue_[this->defer_queue_front_]); this->defer_queue_front_++; @@ -497,19 +496,16 @@ class Scheduler { // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id // Returns the number of items marked for removal // IMPORTANT: Must be called with scheduler lock held - template - size_t mark_matching_items_removed_locked_(Container &container, Component *component, NameType name_type, - const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, - bool match_retry) { + size_t mark_matching_items_removed_locked_(std::vector> &container, + Component *component, NameType name_type, const char *static_name, + uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry) { size_t count = 0; for (auto &item : container) { // Skip nullptr items (can happen in defer_queue_ when items are being processed) // The defer_queue_ uses index-based processing: items are std::moved out but left in the // vector as nullptr until cleanup. Even though this function is called with lock held, // the vector can still contain nullptr items from the processing loop. This check prevents crashes. - if (!item) - continue; - if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { + if (item && this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { this->set_item_removed_(item.get(), true); count++; } @@ -517,29 +513,6 @@ class Scheduler { return count; } - // Template helper to check if any item in a container matches our criteria - // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id - // IMPORTANT: Must be called with scheduler lock held - template - bool has_cancelled_timeout_in_container_locked_(const Container &container, Component *component, NameType name_type, - const char *static_name, uint32_t hash_or_id, - bool match_retry) const { - for (const auto &item : container) { - // Skip nullptr items (can happen in defer_queue_ when items are being processed) - // The defer_queue_ uses index-based processing: items are std::moved out but left in the - // vector as nullptr until cleanup. If this function is called during defer queue processing, - // it will iterate over these nullptr items. This check prevents crashes. - if (!item) - continue; - if (this->is_item_removed_locked_(item.get()) && - this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT, - match_retry, /* skip_removed= */ false)) { - return true; - } - } - return false; - } - Mutex lock_; std::vector> items_; std::vector> to_add_; From d5c9c56fdfcdd0112e1913f05f84e297908460a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Feb 2026 19:41:43 -0600 Subject: [PATCH 13/13] [platformio] Add exponential backoff and session reset to download retries (#14191) --- esphome/platformio_api.py | 41 ++++- tests/unit_tests/test_platformio_api.py | 196 +++++++++++++++++++++++- 2 files changed, 231 insertions(+), 6 deletions(-) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index d42f89d029..5d4065207f 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -5,6 +5,7 @@ import os from pathlib import Path import re import subprocess +import time from typing import Any from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE @@ -44,31 +45,61 @@ def patch_structhash(): def patch_file_downloader(): - """Patch PlatformIO's FileDownloader to retry on PackageException errors.""" + """Patch PlatformIO's FileDownloader to retry on PackageException errors. + + PlatformIO's FileDownloader uses HTTPSession which lacks built-in retry + for 502/503 errors. We add retries with exponential backoff and close the + session between attempts to force a fresh TCP connection, which may route + to a different CDN edge node. + """ from platformio.package.download import FileDownloader from platformio.package.exception import PackageException + if getattr(FileDownloader.__init__, "_esphome_patched", False): + return + original_init = FileDownloader.__init__ def patched_init(self, *args: Any, **kwargs: Any) -> None: - max_retries = 3 + max_retries = 5 for attempt in range(max_retries): try: - return original_init(self, *args, **kwargs) + original_init(self, *args, **kwargs) + return except PackageException as e: if attempt < max_retries - 1: + # Exponential backoff: 2, 4, 8, 16 seconds + delay = 2 ** (attempt + 1) _LOGGER.warning( - "Package download failed: %s. Retrying... (attempt %d/%d)", + "Package download failed: %s. " + "Retrying in %d seconds... (attempt %d/%d)", str(e), + delay, attempt + 1, max_retries, ) + # Close the response and session to free resources + # and force a new TCP connection on retry, which may + # route to a different CDN edge node + # pylint: disable=protected-access,broad-except + try: + if ( + hasattr(self, "_http_response") + and self._http_response is not None + ): + self._http_response.close() + if hasattr(self, "_http_session"): + self._http_session.close() + except Exception: + pass + # pylint: enable=protected-access,broad-except + time.sleep(delay) else: # Final attempt - re-raise raise - return None + patched_init._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access FileDownloader.__init__ = patched_init diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 4d7b635e59..1686144277 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -6,7 +6,7 @@ import os from pathlib import Path import shutil from types import SimpleNamespace -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, patch import pytest @@ -673,6 +673,200 @@ def test_process_stacktrace_bad_alloc( assert state is False +def test_patch_file_downloader_succeeds_first_try() -> None: + """Test patch_file_downloader succeeds on first attempt.""" + mock_exception_cls = type("PackageException", (Exception,), {}) + original_init = MagicMock() + + with patch.dict( + "sys.modules", + { + "platformio": MagicMock(), + "platformio.package": MagicMock(), + "platformio.package.download": SimpleNamespace( + FileDownloader=type("FileDownloader", (), {"__init__": original_init}) + ), + "platformio.package.exception": SimpleNamespace( + PackageException=mock_exception_cls + ), + }, + ): + platformio_api.patch_file_downloader() + + from platformio.package.download import FileDownloader + + instance = object.__new__(FileDownloader) + FileDownloader.__init__(instance, "http://example.com/file.zip") + + original_init.assert_called_once() + + +def test_patch_file_downloader_retries_on_failure() -> None: + """Test patch_file_downloader retries with backoff on PackageException.""" + mock_exception_cls = type("PackageException", (Exception,), {}) + call_count = 0 + + def failing_init(self, *args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise mock_exception_cls(f"502 error attempt {call_count}") + + with ( + patch.dict( + "sys.modules", + { + "platformio": MagicMock(), + "platformio.package": MagicMock(), + "platformio.package.download": SimpleNamespace( + FileDownloader=type( + "FileDownloader", (), {"__init__": failing_init} + ) + ), + "platformio.package.exception": SimpleNamespace( + PackageException=mock_exception_cls + ), + }, + ), + patch("time.sleep") as mock_sleep, + ): + platformio_api.patch_file_downloader() + + from platformio.package.download import FileDownloader + + instance = object.__new__(FileDownloader) + FileDownloader.__init__(instance, "http://example.com/file.zip") + + # Should have been called 3 times (2 failures + 1 success) + assert call_count == 3 + + # Should have slept with exponential backoff: 2s, 4s + assert mock_sleep.call_count == 2 + mock_sleep.assert_any_call(2) + mock_sleep.assert_any_call(4) + + +def test_patch_file_downloader_raises_after_max_retries() -> None: + """Test patch_file_downloader raises after exhausting all retries.""" + mock_exception_cls = type("PackageException", (Exception,), {}) + + def always_failing_init(self, *args, **kwargs): + raise mock_exception_cls("502 error") + + with ( + patch.dict( + "sys.modules", + { + "platformio": MagicMock(), + "platformio.package": MagicMock(), + "platformio.package.download": SimpleNamespace( + FileDownloader=type( + "FileDownloader", (), {"__init__": always_failing_init} + ) + ), + "platformio.package.exception": SimpleNamespace( + PackageException=mock_exception_cls + ), + }, + ), + patch("time.sleep") as mock_sleep, + ): + platformio_api.patch_file_downloader() + + from platformio.package.download import FileDownloader + + instance = object.__new__(FileDownloader) + with pytest.raises(mock_exception_cls, match="502 error"): + FileDownloader.__init__(instance, "http://example.com/file.zip") + + # Should have slept 4 times (before attempts 2-5), not on final attempt + assert mock_sleep.call_count == 4 + mock_sleep.assert_has_calls([call(2), call(4), call(8), call(16)]) + + +def test_patch_file_downloader_closes_session_and_response_between_retries() -> None: + """Test patch_file_downloader closes HTTP session and response between retries.""" + mock_exception_cls = type("PackageException", (Exception,), {}) + mock_session = MagicMock() + mock_response = MagicMock() + call_count = 0 + + def failing_init_with_session(self, *args, **kwargs): + nonlocal call_count + call_count += 1 + self._http_session = mock_session + self._http_response = mock_response + if call_count < 2: + raise mock_exception_cls("502 error") + + with ( + patch.dict( + "sys.modules", + { + "platformio": MagicMock(), + "platformio.package": MagicMock(), + "platformio.package.download": SimpleNamespace( + FileDownloader=type( + "FileDownloader", + (), + {"__init__": failing_init_with_session}, + ) + ), + "platformio.package.exception": SimpleNamespace( + PackageException=mock_exception_cls + ), + }, + ), + patch("time.sleep"), + ): + platformio_api.patch_file_downloader() + + from platformio.package.download import FileDownloader + + instance = object.__new__(FileDownloader) + FileDownloader.__init__(instance, "http://example.com/file.zip") + + # Both response and session should have been closed between retries + mock_response.close.assert_called_once() + mock_session.close.assert_called_once() + + +def test_patch_file_downloader_idempotent() -> None: + """Test patch_file_downloader does not stack wrappers when called multiple times.""" + mock_exception_cls = type("PackageException", (Exception,), {}) + call_count = 0 + + def counting_init(self, *args, **kwargs): + nonlocal call_count + call_count += 1 + + with patch.dict( + "sys.modules", + { + "platformio": MagicMock(), + "platformio.package": MagicMock(), + "platformio.package.download": SimpleNamespace( + FileDownloader=type("FileDownloader", (), {"__init__": counting_init}) + ), + "platformio.package.exception": SimpleNamespace( + PackageException=mock_exception_cls + ), + }, + ): + # Patch multiple times + platformio_api.patch_file_downloader() + platformio_api.patch_file_downloader() + platformio_api.patch_file_downloader() + + from platformio.package.download import FileDownloader + + instance = object.__new__(FileDownloader) + FileDownloader.__init__(instance, "http://example.com/file.zip") + + # Should only be called once, not 3 times from stacked wrappers + assert call_count == 1 + + def test_platformio_log_filter_allows_non_platformio_messages() -> None: """Test that non-platformio logger messages are allowed through.""" log_filter = platformio_api.PlatformioLogFilter()