From 92604017471dd0dbcfbb90f6991f62160545b2e8 Mon Sep 17 00:00:00 2001 From: Daniel Kent <129895318+danielkent-net@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:11:46 -0400 Subject: [PATCH 001/160] [bmp581] Add SPI support for BMP581 (#13124) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + .../components/bmp581_base/bmp581_base.cpp | 11 ++- esphome/components/bmp581_base/bmp581_base.h | 3 + esphome/components/bmp581_spi/__init__.py | 0 esphome/components/bmp581_spi/bmp581_spi.cpp | 73 +++++++++++++++++++ esphome/components/bmp581_spi/bmp581_spi.h | 24 ++++++ esphome/components/bmp581_spi/sensor.py | 48 ++++++++++++ tests/components/bmp581_spi/common.yaml | 9 +++ .../components/bmp581_spi/test.esp32-idf.yaml | 7 ++ .../bmp581_spi/test.esp8266-ard.yaml | 7 ++ .../bmp581_spi/test.rp2040-ard.yaml | 7 ++ 11 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 esphome/components/bmp581_spi/__init__.py create mode 100644 esphome/components/bmp581_spi/bmp581_spi.cpp create mode 100644 esphome/components/bmp581_spi/bmp581_spi.h create mode 100644 esphome/components/bmp581_spi/sensor.py create mode 100644 tests/components/bmp581_spi/common.yaml create mode 100644 tests/components/bmp581_spi/test.esp32-idf.yaml create mode 100644 tests/components/bmp581_spi/test.esp8266-ard.yaml create mode 100644 tests/components/bmp581_spi/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index afe4cdb871..8d297d7b07 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -92,6 +92,7 @@ esphome/components/bmp3xx_i2c/* @latonita esphome/components/bmp3xx_spi/* @latonita esphome/components/bmp581_base/* @danielkent-net @kahrendt esphome/components/bmp581_i2c/* @danielkent-net @kahrendt +esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid esphome/components/bthome_mithermometer/* @nagyrobi diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 89a92de31d..c9d250545b 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -469,14 +469,18 @@ bool BMP581Component::read_temperature_and_pressure_(float &temperature, float & } bool BMP581Component::reset_() { + // - activates interface (only relevant for SPI mode) // - writes reset command to the command register // - waits for sensor to complete reset + // - activates interface (only relevant for SPI mode) // - returns the Power-On-Reboot interrupt status, which is asserted if successful + // activates communication interface (SPI only) + this->activate_interface(); + // writes reset command to BMP's command register if (!this->bmp_write_byte(BMP581_COMMAND, RESET_COMMAND)) { ESP_LOGE(TAG, "Failed to write reset command"); - return false; } @@ -484,6 +488,9 @@ bool BMP581Component::reset_() { // - round up to 3 ms delay(3); + // reactivates communication interface after reset (SPI only) + this->activate_interface(); + // read interrupt status register if (!this->bmp_read_byte(BMP581_INT_STATUS, &this->int_status_.reg)) { ESP_LOGE(TAG, "Failed to read interrupt status register"); @@ -491,7 +498,7 @@ bool BMP581Component::reset_() { return false; } - // Power-On-Reboot bit is asserted if sensor successfully reset + // power-On-Reboot bit is asserted if sensor successfully reset return this->int_status_.bit.por; } diff --git a/esphome/components/bmp581_base/bmp581_base.h b/esphome/components/bmp581_base/bmp581_base.h index d99c420272..c3920512e0 100644 --- a/esphome/components/bmp581_base/bmp581_base.h +++ b/esphome/components/bmp581_base/bmp581_base.h @@ -87,6 +87,9 @@ class BMP581Component : public PollingComponent { virtual bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; virtual bool bmp_write_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; + // Interface activation function. Only used for SPI interface; no-op for I2C. + virtual void activate_interface() {} + sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; diff --git a/esphome/components/bmp581_spi/__init__.py b/esphome/components/bmp581_spi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/bmp581_spi/bmp581_spi.cpp b/esphome/components/bmp581_spi/bmp581_spi.cpp new file mode 100644 index 0000000000..01435880f0 --- /dev/null +++ b/esphome/components/bmp581_spi/bmp581_spi.cpp @@ -0,0 +1,73 @@ +#include +#include + +#include "bmp581_spi.h" +#include "esphome/components/bmp581_base/bmp581_base.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::bmp581_spi { + +static const char *const TAG = "bmp581_spi"; + +// OR (|) register with BMP_SPI_READ for read +inline constexpr uint8_t BMP_SPI_READ = 0x80; + +// AND (&) register with BMP_SPI_WRITE for write +inline constexpr uint8_t BMP_SPI_WRITE = 0x7F; + +void BMP581SPIComponent::dump_config() { + BMP581Component::dump_config(); + LOG_SPI_DEVICE(this); +} + +void BMP581SPIComponent::setup() { + this->spi_setup(); + BMP581Component::setup(); +} + +void BMP581SPIComponent::activate_interface() { + // - forces the device into SPI mode using a dummy read + uint8_t dummy_read = 0; + this->bmp_read_byte(bmp581_base::BMP581_CHIP_ID, &dummy_read); +} + +// In SPI mode, only 7 bits of the register addresses are used; the MSB of register address is not used +// and replaced by a read/write bit (RW = ‘0’ for write and RW = ‘1’ for read). +// Example: address 0xF7 is accessed by using SPI register address 0x77. For write access, the byte +// 0x77 is transferred, for read access, the byte 0xF7 is transferred. +// The expressions BMP_SPI_READ (| with register) and BMP_SPI_WRITE (& with register) +// are defined for readability. +// https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bmp581-ds004.pdf + +bool BMP581SPIComponent::bmp_read_byte(uint8_t a_register, uint8_t *data) { + this->enable(); + this->transfer_byte(a_register | BMP_SPI_READ); + *data = this->transfer_byte(0); + this->disable(); + return true; +} + +bool BMP581SPIComponent::bmp_write_byte(uint8_t a_register, uint8_t data) { + this->enable(); + this->transfer_byte(a_register & BMP_SPI_WRITE); + this->transfer_byte(data); + this->disable(); + return true; +} + +bool BMP581SPIComponent::bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) { + this->enable(); + this->transfer_byte(a_register | BMP_SPI_READ); + this->read_array(data, len); + this->disable(); + return true; +} + +bool BMP581SPIComponent::bmp_write_bytes(uint8_t a_register, uint8_t *data, size_t len) { + this->enable(); + this->transfer_byte(a_register & BMP_SPI_WRITE); + this->write_array(data, len); + this->disable(); + return true; +} +} // namespace esphome::bmp581_spi diff --git a/esphome/components/bmp581_spi/bmp581_spi.h b/esphome/components/bmp581_spi/bmp581_spi.h new file mode 100644 index 0000000000..57f75588d5 --- /dev/null +++ b/esphome/components/bmp581_spi/bmp581_spi.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/bmp581_base/bmp581_base.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::bmp581_spi { + +// BMP581 is technically compatible with SPI Mode0 and Mode3. Default to Mode3. +class BMP581SPIComponent : public esphome::bmp581_base::BMP581Component, + public spi::SPIDevice { + public: + void setup() override; + bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; + bool bmp_write_byte(uint8_t a_register, uint8_t data) override; + bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; + bool bmp_write_bytes(uint8_t a_register, uint8_t *data, size_t len) override; + void dump_config() override; + + protected: + void activate_interface() override; +}; + +} // namespace esphome::bmp581_spi diff --git a/esphome/components/bmp581_spi/sensor.py b/esphome/components/bmp581_spi/sensor.py new file mode 100644 index 0000000000..75f60b2460 --- /dev/null +++ b/esphome/components/bmp581_spi/sensor.py @@ -0,0 +1,48 @@ +import logging + +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.spi import CONF_SPI_MODE +import esphome.config_validation as cv + +from ..bmp581_base import CONFIG_SCHEMA_BASE, to_code_base + +AUTO_LOAD = ["bmp581_base"] +CODEOWNERS = ["@kahrendt", "@danielkent-net"] +DEPENDENCIES = ["spi"] + +_LOGGER = logging.getLogger(__name__) + +VALID_SPI_MODES = { + 0: "MODE0", + "0": "MODE0", + "MODE0": "MODE0", + 3: "MODE3", + "3": "MODE3", + "MODE3": "MODE3", +} + +bmp581_ns = cg.esphome_ns.namespace("bmp581_spi") +BMP581SPIComponent = bmp581_ns.class_( + "BMP581SPIComponent", cg.PollingComponent, spi.SPIDevice +) + + +def check_spi_mode(config): + spi_mode = config.get(CONF_SPI_MODE) + if spi_mode not in VALID_SPI_MODES: + raise cv.Invalid("BMP581 only supports SPI mode 3") + return config + + +CONFIG_SCHEMA = cv.All( + CONFIG_SCHEMA_BASE.extend(spi.spi_device_schema(default_mode="mode3")).extend( + {cv.GenerateID(): cv.declare_id(BMP581SPIComponent)} + ), + check_spi_mode, +) + + +async def to_code(config): + var = await to_code_base(config) + await spi.register_spi_device(var, config) diff --git a/tests/components/bmp581_spi/common.yaml b/tests/components/bmp581_spi/common.yaml new file mode 100644 index 0000000000..f22074f867 --- /dev/null +++ b/tests/components/bmp581_spi/common.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: bmp581_spi + cs_pin: ${cs_pin} + temperature: + name: BMP581 Temperature + iir_filter: 2x + pressure: + name: BMP581 Pressure + oversampling: 128x diff --git a/tests/components/bmp581_spi/test.esp32-idf.yaml b/tests/components/bmp581_spi/test.esp32-idf.yaml new file mode 100644 index 0000000000..a3352cf880 --- /dev/null +++ b/tests/components/bmp581_spi/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/bmp581_spi/test.esp8266-ard.yaml b/tests/components/bmp581_spi/test.esp8266-ard.yaml new file mode 100644 index 0000000000..595f31046a --- /dev/null +++ b/tests/components/bmp581_spi/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + cs_pin: GPIO15 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/bmp581_spi/test.rp2040-ard.yaml b/tests/components/bmp581_spi/test.rp2040-ard.yaml new file mode 100644 index 0000000000..79ea6ce90b --- /dev/null +++ b/tests/components/bmp581_spi/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + +<<: !include common.yaml From f3a31be6d0f4d896db0356b1cabea72741a73921 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 07:32:39 -1000 Subject: [PATCH 002/160] [benchmark] Add climate publish_state and call benchmarks (#15180) --- .../benchmarks/components/climate/__init__.py | 5 + .../components/climate/bench_climate.cpp | 142 ++++++++++++++++++ .../components/climate/benchmark.yaml | 1 + 3 files changed, 148 insertions(+) create mode 100644 tests/benchmarks/components/climate/__init__.py create mode 100644 tests/benchmarks/components/climate/bench_climate.cpp create mode 100644 tests/benchmarks/components/climate/benchmark.yaml diff --git a/tests/benchmarks/components/climate/__init__.py b/tests/benchmarks/components/climate/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/climate/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/climate/bench_climate.cpp b/tests/benchmarks/components/climate/bench_climate.cpp new file mode 100644 index 0000000000..316a72b2b6 --- /dev/null +++ b/tests/benchmarks/components/climate/bench_climate.cpp @@ -0,0 +1,142 @@ +#include + +#include "esphome/components/climate/climate.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Climate for benchmarking — control() is a no-op. +class BenchClimate : public climate::Climate { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + climate::ClimateTraits traits() override { return this->traits_; } + + climate::ClimateTraits traits_; + + protected: + void control(const climate::ClimateCall & /*call*/) override {} +}; + +// Helper to create a typical HVAC climate device for benchmarks. +// Note: setup() is not called (no preferences backend), so save_state_() +// is effectively a no-op. This benchmarks the call/validation path, not persistence. +static void setup_hvac_climate(BenchClimate &climate) { + climate.configure("test_climate"); + climate.traits_.set_supported_modes({ + climate::CLIMATE_MODE_OFF, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_FAN_ONLY, + }); + climate.traits_.set_supported_fan_modes({ + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, + }); + climate.traits_.set_supported_swing_modes({ + climate::CLIMATE_SWING_OFF, + climate::CLIMATE_SWING_BOTH, + climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, + }); + climate.traits_.set_supported_presets({ + climate::CLIMATE_PRESET_NONE, + climate::CLIMATE_PRESET_HOME, + climate::CLIMATE_PRESET_AWAY, + }); + climate.traits_.set_visual_min_temperature(16.0f); + climate.traits_.set_visual_max_temperature(30.0f); + climate.traits_.set_visual_target_temperature_step(0.5f); + climate.traits_.set_visual_current_temperature_step(0.1f); + climate.traits_.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_ACTION); +} + +// --- Climate::publish_state() with temperature update --- +// Measures the publish path for a thermostat reporting state — +// the hot path during HVAC operation. + +static void ClimatePublish_State(benchmark::State &state) { + BenchClimate climate; + setup_hvac_climate(climate); + climate.mode = climate::CLIMATE_MODE_HEAT; + climate.action = climate::CLIMATE_ACTION_HEATING; + climate.target_temperature = 22.0f; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + climate.current_temperature = 20.0f + static_cast(i % 100) / 10.0f; + climate.publish_state(); + } + benchmark::DoNotOptimize(climate.current_temperature); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ClimatePublish_State); + +// --- Climate::publish_state() with callback --- +// Measures callback dispatch overhead. + +static void ClimatePublish_WithCallback(benchmark::State &state) { + BenchClimate climate; + setup_hvac_climate(climate); + climate.mode = climate::CLIMATE_MODE_HEAT; + climate.target_temperature = 22.0f; + + uint64_t callback_count = 0; + climate.add_on_state_callback([&callback_count](climate::Climate & /*c*/) { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + climate.current_temperature = 20.0f + static_cast(i % 100) / 10.0f; + climate.publish_state(); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ClimatePublish_WithCallback); + +// --- ClimateCall::perform() set target temperature --- +// The most common climate call — adjusting the thermostat setpoint. + +static void ClimateCall_SetTemperature(benchmark::State &state) { + BenchClimate climate; + setup_hvac_climate(climate); + climate.mode = climate::CLIMATE_MODE_HEAT; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float temp = 18.0f + static_cast(i % 25) * 0.5f; + climate.make_call().set_target_temperature(temp).perform(); + } + benchmark::DoNotOptimize(climate.target_temperature); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ClimateCall_SetTemperature); + +// --- ClimateCall::perform() mode change with fan --- +// Exercises the validation path with multiple fields set. + +static void ClimateCall_ModeChange(benchmark::State &state) { + BenchClimate climate; + setup_hvac_climate(climate); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + auto mode = (i % 2 == 0) ? climate::CLIMATE_MODE_HEAT : climate::CLIMATE_MODE_COOL; + auto fan = (i % 2 == 0) ? climate::CLIMATE_FAN_HIGH : climate::CLIMATE_FAN_LOW; + climate.make_call().set_mode(mode).set_fan_mode(fan).set_target_temperature(22.0f).perform(); + } + benchmark::DoNotOptimize(climate.mode); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ClimateCall_ModeChange); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/climate/benchmark.yaml b/tests/benchmarks/components/climate/benchmark.yaml new file mode 100644 index 0000000000..8e79ed0ae7 --- /dev/null +++ b/tests/benchmarks/components/climate/benchmark.yaml @@ -0,0 +1 @@ +climate: From 689828436107c797a0525dbf5f3b86f96189ec47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 07:32:54 -1000 Subject: [PATCH 003/160] [benchmark] Add cover publish_state and call benchmarks (#15179) --- tests/benchmarks/components/cover/__init__.py | 5 + .../components/cover/bench_cover_publish.cpp | 107 ++++++++++++++++++ .../components/cover/benchmark.yaml | 1 + 3 files changed, 113 insertions(+) create mode 100644 tests/benchmarks/components/cover/__init__.py create mode 100644 tests/benchmarks/components/cover/bench_cover_publish.cpp create mode 100644 tests/benchmarks/components/cover/benchmark.yaml diff --git a/tests/benchmarks/components/cover/__init__.py b/tests/benchmarks/components/cover/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/cover/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/cover/bench_cover_publish.cpp b/tests/benchmarks/components/cover/bench_cover_publish.cpp new file mode 100644 index 0000000000..794d967edb --- /dev/null +++ b/tests/benchmarks/components/cover/bench_cover_publish.cpp @@ -0,0 +1,107 @@ +#include + +#include "esphome/components/cover/cover.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Cover for benchmarking — control() is a no-op. +class BenchCover : public cover::Cover { + public: + cover::CoverTraits get_traits() override { return this->traits_; } + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + cover::CoverTraits traits_; + + protected: + void control(const cover::CoverCall & /*call*/) override {} +}; + +// --- Cover::publish_state() with position updates --- +// Measures the publish path for a garage door reporting position +// during open/close — the hot path during movement. + +static void CoverPublish_Position(benchmark::State &state) { + BenchCover cover; + cover.configure("test_cover"); + cover.traits_.set_supports_position(true); + cover.traits_.set_supports_tilt(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + cover.position = static_cast(i % 101) / 100.0f; + cover.current_operation = (i % 2 == 0) ? cover::COVER_OPERATION_OPENING : cover::COVER_OPERATION_CLOSING; + cover.publish_state(false); + } + benchmark::DoNotOptimize(cover.position); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CoverPublish_Position); + +// --- Cover::publish_state() with callback --- +// Measures callback dispatch overhead. + +static void CoverPublish_WithCallback(benchmark::State &state) { + BenchCover cover; + cover.configure("test_cover"); + cover.traits_.set_supports_position(true); + + uint64_t callback_count = 0; + cover.add_on_state_callback([&callback_count]() { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + cover.position = static_cast(i % 101) / 100.0f; + cover.publish_state(false); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CoverPublish_WithCallback); + +// --- CoverCall::perform() open/close cycle --- +// Measures the full call path: validation + control delegation. + +static void CoverCall_OpenClose(benchmark::State &state) { + BenchCover cover; + cover.configure("test_cover"); + cover.traits_.set_supports_position(true); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + if (i % 2 == 0) { + cover.make_call().set_command_open().perform(); + } else { + cover.make_call().set_command_close().perform(); + } + } + benchmark::DoNotOptimize(cover.position); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CoverCall_OpenClose); + +// --- CoverCall::perform() set position --- +// Measures the position-setting call path. + +static void CoverCall_SetPosition(benchmark::State &state) { + BenchCover cover; + cover.configure("test_cover"); + cover.traits_.set_supports_position(true); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float pos = static_cast(i % 101) / 100.0f; + cover.make_call().set_position(pos).perform(); + } + benchmark::DoNotOptimize(cover.position); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CoverCall_SetPosition); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/cover/benchmark.yaml b/tests/benchmarks/components/cover/benchmark.yaml new file mode 100644 index 0000000000..477724be5a --- /dev/null +++ b/tests/benchmarks/components/cover/benchmark.yaml @@ -0,0 +1 @@ +cover: From 02e23eb386bd3fde73c34a41cebdf2b2a08b41af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 07:33:10 -1000 Subject: [PATCH 004/160] [benchmark] Add light call and publish benchmarks (#15176) --- tests/benchmarks/components/light/__init__.py | 28 ++ .../components/light/bench_light_call.cpp | 253 ++++++++++++++++++ .../components/light/benchmark.yaml | 1 + 3 files changed, 282 insertions(+) create mode 100644 tests/benchmarks/components/light/__init__.py create mode 100644 tests/benchmarks/components/light/bench_light_call.cpp create mode 100644 tests/benchmarks/components/light/benchmark.yaml diff --git a/tests/benchmarks/components/light/__init__.py b/tests/benchmarks/components/light/__init__.py new file mode 100644 index 0000000000..233a3c246e --- /dev/null +++ b/tests/benchmarks/components/light/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components.light import generate_gamma_table +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Light benchmarks need USE_LIGHT_GAMMA_LUT defined and a gamma table + # with external linkage that the benchmark .cpp can reference. + manifest.enable_codegen() + original_to_code = manifest.to_code + + async def to_code(config): + await original_to_code(config) + cg.add_define("USE_LIGHT_GAMMA_LUT") + # Use the light component's own generate_gamma_table() so the + # benchmark stays in sync with any formula changes. + forward = generate_gamma_table(2.8) + values = ", ".join(f"0x{int(v):04X}" for v in forward) + # Use extern-visible (non-static) array so the benchmark .cpp + # can reference it via extern declaration. + cg.add_global( + cg.RawStatement( + f"extern const uint16_t bench_gamma_2_8_fwd[256] PROGMEM = {{{values}}};" + ) + ) + + to_code.priority = original_to_code.priority + manifest.to_code = to_code diff --git a/tests/benchmarks/components/light/bench_light_call.cpp b/tests/benchmarks/components/light/bench_light_call.cpp new file mode 100644 index 0000000000..c1ef0c425e --- /dev/null +++ b/tests/benchmarks/components/light/bench_light_call.cpp @@ -0,0 +1,253 @@ +#include + +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +// Gamma 2.8 forward LUT generated by the light component's Python codegen +// (see tests/benchmarks/components/light/__init__.py which calls generate_gamma_table()) +extern const uint16_t bench_gamma_2_8_fwd[256]; + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal LightOutput for benchmarking — no real hardware interaction. +class BenchLightOutput : public light::LightOutput { + public: + light::LightTraits get_traits() override { return this->traits_; } + void write_state(light::LightState * /*state*/) override {} + + light::LightTraits traits_; +}; + +// Test subclass to access protected configure_entity_() for benchmark setup. +class TestLightState : public light::LightState { + public: + using LightState::LightState; + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } +}; + +// Helper to create a configured RGBWW light state for benchmarks. +// Note: setup() is not called (no preferences backend), so save_remote_values_() +// is effectively a no-op. This benchmarks the call/validation path, not persistence. +static void setup_rgbww_light(BenchLightOutput &output, TestLightState &light) { + output.traits_.set_supported_color_modes({light::ColorMode::RGB_COLD_WARM_WHITE}); + output.traits_.set_min_mireds(153.0f); + output.traits_.set_max_mireds(500.0f); + light.configure("test_light"); + light.set_default_transition_length(0); + light.set_gamma_correct(2.8f); + light.set_gamma_table(bench_gamma_2_8_fwd); + light.set_restore_mode(light::LIGHT_ALWAYS_OFF); +} + +// --- LightCall::perform() with instant RGB color change (Home Assistant API path) --- +// Measures the full call path: validation, set_immediately_, publish, and save. +// HA sends color_mode explicitly since API 1.6. + +static void LightCall_RGBInstant(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + // Turn on first so subsequent calls are color changes + light.make_call().set_state(true).set_brightness(1.0f).set_color_brightness(1.0f).set_transition_length(0).perform(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float v = static_cast(i % 256) / 255.0f; + light.make_call() + .set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE) + .set_red(v) + .set_green(1.0f - v) + .set_blue(v * 0.5f) + .set_transition_length(0) + .perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_RGBInstant); + +// --- LightCall::perform() turn on/off cycle (Home Assistant API path) --- +// HA sends color_mode explicitly since API 1.6, skipping compute_color_mode_(). + +static void LightCall_ToggleOnOff(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + light.make_call() + .set_state(i % 2 == 0) + .set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE) + .set_transition_length(0) + .perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_ToggleOnOff); + +// --- LightCall::perform() turn on/off via MQTT --- +// MQTT never sends color_mode, so compute_color_mode_() runs every call. + +static void LightCall_ToggleOnOff_MQTT(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + light.make_call().set_state(i % 2 == 0).set_transition_length(0).perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_ToggleOnOff_MQTT); + +// --- LightCall::perform() with color temperature via MQTT --- +// Exercises the transform_parameters_() path that converts color_temperature +// to cold/warm white fractions. MQTT never sends color_mode, so this also +// hits compute_color_mode_() every call. Modern HA avoids this path entirely +// by converting color temp to CW/WW client-side. + +static void LightCall_ColorTemperature_MQTT(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + light.make_call().set_state(true).set_brightness(1.0f).set_transition_length(0).perform(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + // Sweep through color temperature range + float ct = 153.0f + static_cast(i % 348); + light.make_call().set_color_temperature(ct).set_transition_length(0).perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_ColorTemperature_MQTT); + +// --- LightCall::perform() with 1s transition (Home Assistant API path) --- +// Exercises start_transition_() which allocates a LightTransformer. +// This is the default HA path when transition_length > 0. + +static void LightCall_Transition(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + light.make_call().set_state(true).set_brightness(1.0f).set_transition_length(0).perform(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float v = static_cast(i % 256) / 255.0f; + light.make_call() + .set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE) + .set_red(v) + .set_green(1.0f - v) + .set_blue(v * 0.5f) + .set_transition_length(1000) + .perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_Transition); + +// --- LightCall::perform() with cold/warm white (Home Assistant API path) --- +// Mirrors what modern HA sends: explicit color_mode with direct cold_white +// and warm_white values. HA converts color temp to CW/WW client-side for +// CWWW lights (API >= 1.6), so this is the primary HA path. + +static void LightCall_ColdWarmWhite(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + light.make_call().set_state(true).set_brightness(1.0f).set_transition_length(0).perform(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float frac = static_cast(i % 256) / 255.0f; + light.make_call() + .set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE) + .set_cold_white(1.0f - frac) + .set_warm_white(frac) + .set_transition_length(0) + .perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_ColdWarmWhite); + +// --- LightState::publish_state() with a remote values listener --- +// Measures listener notification overhead. + +static void LightPublish_WithListener(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + struct TestListener : public light::LightRemoteValuesListener { + void on_light_remote_values_update() override { count_++; } + uint64_t count_{0}; + } listener; + light.add_remote_values_listener(&listener); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + light.publish_state(); + } + benchmark::DoNotOptimize(listener.count_); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightPublish_WithListener); + +// --- current_values_as_rgbww output conversion with gamma LUT --- +// Measures the output conversion path that real light drivers call +// from write_state() to get hardware PWM values, including gamma +// table lookups via the LUT generated by Python codegen. + +static void LightOutput_RGBWW(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + light.make_call() + .set_state(true) + .set_brightness(0.8f) + .set_color_brightness(0.6f) + .set_red(1.0f) + .set_green(0.5f) + .set_blue(0.2f) + .set_cold_white(0.7f) + .set_warm_white(0.3f) + .set_transition_length(0) + .perform(); + + float r, g, b, cw, ww; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + light.current_values_as_rgbww(&r, &g, &b, &cw, &ww); + } + benchmark::DoNotOptimize(r); + benchmark::DoNotOptimize(cw); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightOutput_RGBWW); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/light/benchmark.yaml b/tests/benchmarks/components/light/benchmark.yaml new file mode 100644 index 0000000000..2b7c938581 --- /dev/null +++ b/tests/benchmarks/components/light/benchmark.yaml @@ -0,0 +1 @@ +light: From c2456409bd4bde9b793a5c6c98bebbc18f62511d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:39:19 -0400 Subject: [PATCH 005/160] [core] Improve clean-all with no arguments (#15184) --- esphome/writer.py | 10 ++++++++ tests/unit_tests/test_writer.py | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/esphome/writer.py b/esphome/writer.py index 4aac16ffd4..06a2230118 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -476,6 +476,16 @@ def clean_all(configuration: list[str]): data_dirs.append(Path(env_data_dir)) if env_build_path := os.environ.get("ESPHOME_BUILD_PATH"): data_dirs.append(Path(env_build_path)) + if not data_dirs: + # No config files or known data dirs, check current directory + cwd_esphome = Path.cwd() / ".esphome" + if cwd_esphome.is_dir(): + data_dirs.append(cwd_esphome) + else: + _LOGGER.warning( + "No configuration files specified and no .esphome directory found in current directory. " + "Pass YAML files or a configuration directory to clean build artifacts." + ) # Clean build dir for dir in data_dirs: diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 6ace38a7d7..940a394c08 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -990,6 +990,47 @@ def test_clean_all_ignores_empty_env_vars( assert marker.exists() +@patch("esphome.writer.CORE") +def test_clean_all_no_args_with_esphome_dir( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all with no args cleans .esphome in cwd.""" + esphome_dir = tmp_path / ".esphome" + esphome_dir.mkdir() + (esphome_dir / "dummy.txt").write_text("x") + + from esphome.writer import clean_all + + with ( + caplog.at_level("INFO"), + patch("esphome.writer.Path.cwd", return_value=tmp_path), + ): + clean_all([]) + + assert esphome_dir.exists() + assert not (esphome_dir / "dummy.txt").exists() + + +@patch("esphome.writer.CORE") +def test_clean_all_no_args_no_esphome_dir( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all with no args and no .esphome dir warns.""" + from esphome.writer import clean_all + + with ( + caplog.at_level("WARNING"), + patch("esphome.writer.Path.cwd", return_value=tmp_path), + ): + clean_all([]) + + assert "No configuration files specified" in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all( mock_core: MagicMock, From bf89a191f06a4fea3c3b9014f1d46200c89fa2df Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:39:35 -0400 Subject: [PATCH 006/160] [wifi] Guard coex_background_scan with CONFIG_SOC_WIFI_SUPPORTED (#15187) --- esphome/components/wifi/wifi_component_esp_idf.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 1b80adc82e..d8b3db9667 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -989,9 +989,11 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { } // When scanning while connected (roaming), return to home channel between // each scanned channel to maintain the connection (helps with BLE/WiFi coexistence) +#ifdef CONFIG_SOC_WIFI_SUPPORTED if (this->roaming_state_ == RoamingState::SCANNING) { config.coex_background_scan = true; } +#endif esp_err_t err = esp_wifi_scan_start(&config, false); if (err != ESP_OK) { From d9ada4536cbcaacdba36ea43806753841e06d515 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:58:12 +0100 Subject: [PATCH 007/160] [nextion] Fix leading space in pressed color string commands (#15190) --- esphome/components/nextion/nextion_commands.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index 2adf314a2e..4ddbfbee6a 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -106,7 +106,7 @@ void Nextion::set_component_pressed_foreground_color(const char *component, uint } void Nextion::set_component_pressed_foreground_color(const char *component, const char *color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_foreground_color", " %s.pco2=%s", component, color); + this->add_no_result_to_queue_with_printf_("set_component_pressed_foreground_color", "%s.pco2=%s", component, color); } void Nextion::set_component_pressed_foreground_color(const char *component, Color color) { @@ -134,7 +134,7 @@ void Nextion::set_component_pressed_font_color(const char *component, uint16_t c } void Nextion::set_component_pressed_font_color(const char *component, const char *color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_font_color", " %s.pco2=%s", component, color); + this->add_no_result_to_queue_with_printf_("set_component_pressed_font_color", "%s.pco2=%s", component, color); } void Nextion::set_component_pressed_font_color(const char *component, Color color) { From 1edf952ddacb7a0ad4d7ea1d106bd340642e6c99 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 27 Mar 2026 04:59:06 +1000 Subject: [PATCH 008/160] [font] Add unit tests verifying correct processing of glyphs (#15178) --- tests/component_tests/font/.gitattributes | 2 + .../component_tests/font/NotoSans-Regular.ttf | Bin 0 -> 455188 bytes tests/component_tests/font/__init__.py | 0 tests/component_tests/font/test_font.py | 337 ++++++++++++++++++ tests/components/font/.gitattributes | 3 +- 5 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/font/.gitattributes create mode 100644 tests/component_tests/font/NotoSans-Regular.ttf create mode 100644 tests/component_tests/font/__init__.py create mode 100644 tests/component_tests/font/test_font.py diff --git a/tests/component_tests/font/.gitattributes b/tests/component_tests/font/.gitattributes new file mode 100644 index 0000000000..4df6726184 --- /dev/null +++ b/tests/component_tests/font/.gitattributes @@ -0,0 +1,2 @@ +*.pcf -text +*.ttf -text diff --git a/tests/component_tests/font/NotoSans-Regular.ttf b/tests/component_tests/font/NotoSans-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a1b8994edeacd70067de843a4691b15a0ce5921b GIT binary patch literal 455188 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
dI>|@x^aDd?;!(oP_496KxGMr{O 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

&WAr92Eg5`rwCC}mWJg|9LrV;m!6{5%0^P7W!7 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 z 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�gj|olzjY)xy*7(9C$-oV|j}f$g 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 xDKjUtq!KIxvq85wFR`Eiv1%-R3| literal 0 HcmV?d00001 diff --git a/tests/component_tests/font/__init__.py b/tests/component_tests/font/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/font/test_font.py b/tests/component_tests/font/test_font.py new file mode 100644 index 0000000000..55e27ae84c --- /dev/null +++ b/tests/component_tests/font/test_font.py @@ -0,0 +1,337 @@ +"""Tests for the font component. + +Focuses on verifying that long multi-byte (Chinese/CJK) glyph strings +are correctly processed through the font configuration pipeline. +""" + +import functools +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components.font import ( + CONF_BPP, + CONF_EXTRAS, + CONF_GLYPHSETS, + CONF_IGNORE_MISSING_GLYPHS, + CONF_RAW_GLYPH_ID, + FONT_CACHE, + flatten, + glyph_comparator, + to_code, + validate_font_config, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_FILE, + CONF_GLYPHS, + CONF_ID, + CONF_PATH, + CONF_RAW_DATA_ID, + CONF_SIZE, + CONF_TYPE, +) + +FONT_DIR = Path(__file__).parent +FONT_PATH = FONT_DIR / "NotoSans-Regular.ttf" + +# 200 unique CJK Unified Ideograph characters (U+4E00..U+4EC7) +CHINESE_200 = "".join(chr(cp) for cp in range(0x4E00, 0x4EC8)) + + +def _file_conf() -> dict: + return {CONF_PATH: str(FONT_PATH), CONF_TYPE: "local"} + + +def _make_config( + glyphs: list[str], + *, + ignore_missing: bool = False, + size: int = 20, + bpp: int = 1, + extras: list | None = None, + glyphsets: list | None = None, +) -> dict: + """Build a config dict matching what FONT_SCHEMA produces.""" + return { + CONF_FILE: _file_conf(), + CONF_GLYPHS: glyphs, + CONF_GLYPHSETS: glyphsets or [], + CONF_IGNORE_MISSING_GLYPHS: ignore_missing, + CONF_SIZE: size, + CONF_BPP: bpp, + CONF_EXTRAS: extras or [], + } + + +@pytest.fixture(autouse=True) +def _load_font(): + """Load the test font into FONT_CACHE and clean up afterwards.""" + fc = _file_conf() + FONT_CACHE[fc] = FONT_PATH + yield + FONT_CACHE.store.clear() + + +# ---------- flatten / glyph_comparator helpers ---------- + + +def test_flatten_splits_chinese_string_into_chars(): + """A single string of 200 Chinese characters must become 200 individual chars.""" + result = flatten([CHINESE_200]) + assert len(result) == 200 + assert all(len(c) == 1 for c in result) + assert result[0] == "\u4e00" + assert result[-1] == "\u4ec7" + + +def test_flatten_multiple_chinese_strings(): + """Multiple glyph strings are concatenated then split correctly.""" + s1 = CHINESE_200[:100] + s2 = CHINESE_200[100:] + result = flatten([list(s1), list(s2)]) + assert len(result) == 200 + + +def test_glyph_comparator_orders_chinese_by_utf8(): + """glyph_comparator must order CJK characters by their UTF-8 byte sequence.""" + chars = list(CHINESE_200[:10]) + sorted_chars = sorted(chars, key=functools.cmp_to_key(glyph_comparator)) + # CJK block is contiguous and UTF-8 order matches codepoint order here + assert sorted_chars == chars + + +def test_glyph_comparator_mixed_ascii_and_chinese(): + """ASCII characters sort before CJK characters (lower UTF-8 bytes).""" + assert glyph_comparator("A", "\u4e00") == -1 + assert glyph_comparator("\u4e00", "A") == 1 + assert glyph_comparator("\u4e00", "\u4e00") == 0 + + +# ---------- validate_font_config ---------- + + +def test_long_chinese_glyphs_raises_missing_error(): + """200 Chinese chars not present in NotoSans must raise Invalid with the correct count.""" + config = _make_config([CHINESE_200]) + with pytest.raises(cv.Invalid, match=r"missing 200 glyphs"): + validate_font_config(config) + + +def test_long_chinese_glyphs_error_mentions_overflow(): + """When more than 10 glyphs are missing the error should mention the remainder.""" + config = _make_config([CHINESE_200]) + with pytest.raises(cv.Invalid, match=r"and 190 more"): + validate_font_config(config) + + +def test_duplicate_chinese_glyphs_detected(): + """Duplicate CJK characters within a single glyph string must be caught.""" + duped = "\u4e00\u4e01\u4e00" # first char repeated + config = _make_config([duped]) + with pytest.raises(cv.Invalid, match="duplicate"): + validate_font_config(config) + + +def test_duplicate_chinese_across_strings(): + """Duplicates across separate glyph strings are also caught.""" + config = _make_config(["\u4e00\u4e01", "\u4e01\u4e02"]) + with pytest.raises(cv.Invalid, match="duplicate"): + validate_font_config(config) + + +def test_no_false_duplicates_in_200_unique_chinese(): + """200 unique CJK characters must not trigger the duplicate check.""" + config = _make_config([CHINESE_200]) + # Should not raise duplicate error — it should reach the missing-glyph check instead + with pytest.raises(cv.Invalid, match="missing"): + validate_font_config(config) + + +def test_valid_latin_glyphs_pass_validation(): + """Latin characters present in NotoSans-Regular pass validation without error.""" + config = _make_config(["ABCabc123"]) + result = validate_font_config(config) + assert result is not None + assert result[CONF_SIZE] == 20 + + +def test_long_latin_glyphs_pass_validation(): + """A long string of supported Latin glyphs passes validation.""" + # 95 printable ASCII characters that NotoSans supports + latin = "".join(chr(cp) for cp in range(0x21, 0x7F)) + config = _make_config([latin]) + result = validate_font_config(config) + assert result is not None + + +def test_mixed_latin_and_chinese_glyphs_error(): + """Mixing valid Latin and invalid Chinese chars reports missing Chinese glyphs.""" + chinese_10 = CHINESE_200[:10] + config = _make_config(["ABC", chinese_10]) + with pytest.raises(cv.Invalid, match=r"missing 10 glyphs"): + validate_font_config(config) + + +def test_single_chinese_char_glyph(): + """A single Chinese character is correctly handled as one glyph.""" + config = _make_config(["\u4e00"]) + with pytest.raises(cv.Invalid, match=r"missing 1 glyph[^s]"): + validate_font_config(config) + + +def test_chinese_glyphs_as_individual_list_items(): + """Chinese chars provided as separate list items are handled the same as a single string.""" + chars_as_list = list(CHINESE_200[:50]) + config = _make_config(chars_as_list) + with pytest.raises(cv.Invalid, match=r"missing 50 glyphs"): + validate_font_config(config) + + +# ---------- YAML parsing ---------- + + +def test_yaml_long_latin_glyphs_parsed_and_validated(tmp_path): + """200 Latin Extended chars on a single YAML line are parsed intact and pass validation.""" + from esphome.yaml_util import load_yaml + + latin_long = "".join(chr(cp) for cp in range(0x100, 0x1C8)) + yaml_file = tmp_path / "font_test.yaml" + yaml_file.write_text( + f'font:\n - file: "NotoSans-Regular.ttf"\n glyphs: "{latin_long}"\n', + encoding="utf-8", + ) + + parsed = load_yaml(yaml_file) + raw_glyphs = parsed["font"][0]["glyphs"] + + # YAML must preserve every Unicode character on the single line + assert raw_glyphs == latin_long + assert len(raw_glyphs) == 200 + + # Feed through validate_font_config to confirm all glyphs are accepted + config = _make_config([raw_glyphs]) + result = validate_font_config(config) + assert result is not None + + +@pytest.mark.parametrize( + "glyphs_str", + [ + " ABC", # space at start + "AB CD", # space in middle + "ABC ", # space at end + ], + ids=["start", "middle", "end"], +) +def test_yaml_space_in_glyphs_preserved(tmp_path, glyphs_str): + """A space character in a glyphs string must survive YAML round-trip and validation.""" + from esphome.yaml_util import load_yaml + + yaml_file = tmp_path / "font_test.yaml" + yaml_file.write_text( + f'font:\n - file: "NotoSans-Regular.ttf"\n glyphs: "{glyphs_str}"\n', + encoding="utf-8", + ) + + parsed = load_yaml(yaml_file) + raw_glyphs = parsed["font"][0]["glyphs"] + + assert raw_glyphs == glyphs_str + assert " " in raw_glyphs + + # Space and ASCII letters are all in NotoSans — validation must pass + config = _make_config([raw_glyphs]) + result = validate_font_config(config) + assert result is not None + + +# ---------- to_code generation ---------- + + +# 200 unique Latin Extended characters (U+0100..U+01C7), all present in NotoSans +LATIN_LONG = "".join(chr(cp) for cp in range(0x100, 0x1C8)) + + +@pytest.fixture +def mock_cg(): + """Mock all cg codegen functions used by to_code.""" + with ( + patch("esphome.components.font.cg.add_define") as mock_define, + patch("esphome.components.font.cg.progmem_array") as mock_progmem, + patch("esphome.components.font.cg.static_const_array") as mock_static, + patch("esphome.components.font.cg.new_Pvariable") as mock_new_pvar, + ): + mock_progmem.return_value = MagicMock() + mock_static.return_value = MagicMock() + yield { + "add_define": mock_define, + "progmem_array": mock_progmem, + "static_const_array": mock_static, + "new_Pvariable": mock_new_pvar, + } + + +@pytest.mark.asyncio +async def test_to_code_long_latin_generates_all_glyphs(mock_cg): + """to_code must generate glyph data for every character in a long Latin string.""" + glyph_count = len(LATIN_LONG) # 200 + config = _make_config([LATIN_LONG]) + config[CONF_ID] = MagicMock() + config[CONF_RAW_DATA_ID] = MagicMock() + config[CONF_RAW_GLYPH_ID] = MagicMock() + + await to_code(config) + + # USE_FONT define must be emitted + mock_cg["add_define"].assert_any_call("USE_FONT") + + # progmem_array receives the combined bitmap data (non-empty) + mock_cg["progmem_array"].assert_called_once() + bitmap_data = mock_cg["progmem_array"].call_args.args[1] + assert len(bitmap_data) > 0 + + # static_const_array receives one entry per unique glyph + mock_cg["static_const_array"].assert_called_once() + glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + assert len(glyph_initializer) == glyph_count + + # new_Pvariable is called with the correct glyph count + mock_cg["new_Pvariable"].assert_called_once() + pvar_args = mock_cg["new_Pvariable"].call_args.args + assert pvar_args[2] == glyph_count # len(glyph_initializer) + assert pvar_args[8] == 1 # bpp + + +@pytest.mark.asyncio +async def test_to_code_glyph_entries_contain_expected_fields(mock_cg): + """Each glyph initializer entry must have 7 fields: codepoint, data ptr, advance, offset_x, offset_y, w, h.""" + config = _make_config([LATIN_LONG]) + config[CONF_ID] = MagicMock() + config[CONF_RAW_DATA_ID] = MagicMock() + config[CONF_RAW_GLYPH_ID] = MagicMock() + + await to_code(config) + + glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + for entry in glyph_initializer: + assert len(entry) == 7, f"Glyph entry should have 7 fields, got {len(entry)}" + codepoint = entry[0] + assert isinstance(codepoint, int) + assert 0x100 <= codepoint <= 0x1C7 + + +@pytest.mark.asyncio +async def test_to_code_glyphs_sorted_by_utf8(mock_cg): + """Glyphs in the initializer must be sorted by UTF-8 byte order.""" + config = _make_config([LATIN_LONG]) + config[CONF_ID] = MagicMock() + config[CONF_RAW_DATA_ID] = MagicMock() + config[CONF_RAW_GLYPH_ID] = MagicMock() + + await to_code(config) + + glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + codepoints = [entry[0] for entry in glyph_initializer] + assert codepoints == sorted(codepoints) diff --git a/tests/components/font/.gitattributes b/tests/components/font/.gitattributes index 63ab00e9f2..4df6726184 100644 --- a/tests/components/font/.gitattributes +++ b/tests/components/font/.gitattributes @@ -1 +1,2 @@ -*.pcf -text +*.pcf -text +*.ttf -text From a008c27fcfc8e1f4fd1241ce4597eef4ea0be15b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 09:01:08 -1000 Subject: [PATCH 009/160] [climate] Avoid duplicate get_traits() in publish_state (#15181) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/climate/climate.cpp | 5 ++--- esphome/components/climate/climate.h | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 5cbe9a5daf..32cac0961c 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -367,7 +367,7 @@ optional Climate::restore_state_() { return recovered; } -void Climate::save_state_() { +void Climate::save_state_(const ClimateTraits &traits) { #if (defined(USE_ESP32) || (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0))) && \ !defined(CLANG_TIDY) #pragma GCC diagnostic ignored "-Wclass-memaccess" @@ -382,7 +382,6 @@ void Climate::save_state_() { #endif state.mode = this->mode; - auto traits = this->get_traits(); if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { state.target_temperature_low = this->target_temperature_low; @@ -480,7 +479,7 @@ void Climate::publish_state() { ControllerRegistry::notify_climate_update(this); #endif // Save state - this->save_state_(); + this->save_state_(traits); } ClimateTraits Climate::get_traits() { diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index e2cb743c0a..0251365dd8 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -335,7 +335,8 @@ class Climate : public EntityBase { /** Internal method to save the state of the climate device to recover memory. This is automatically * called from publish_state() */ - void save_state_(); + void save_state_(const ClimateTraits &traits); + void save_state_() { this->save_state_(this->traits()); } void dump_traits_(const char *tag); From 1e2c410abfae7a1c1a78cfff62c9507f307b582f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:47:18 -1000 Subject: [PATCH 010/160] Bump cryptography from 46.0.5 to 46.0.6 (#15193) 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 ce735f398a..c74dd265c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==46.0.5 +cryptography==46.0.6 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From 3152642571a32108c87e90390722808c65533b4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:48:06 -1000 Subject: [PATCH 011/160] Bump codecov/codecov-action from 5.5.3 to 6.0.0 (#15194) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 965e23870d..ab7a750388 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,7 +154,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From 81f0aa1168b8b993451b3673f28bd57763148cbe Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:54:50 +0100 Subject: [PATCH 012/160] [nextion] Replace `or`/`and` operators and missing `this->` (#15191) --- esphome/components/nextion/nextion.cpp | 2 +- .../components/nextion/nextion_upload_arduino.cpp | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index ac17e14312..612bfbc968 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -90,7 +90,7 @@ bool Nextion::check_connect_() { #endif // NEXTION_PROTOCOL_LOG ESP_LOGW(TAG, "Not connected"); - comok_sent_ = 0; + this->comok_sent_ = 0; return false; } diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 6c454ab745..f59b708002 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -22,9 +22,9 @@ static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { uint32_t range_size = this->tft_size_ - range_start; ESP_LOGV(TAG, "Heap: %" PRIu32, EspClass::getFreeHeap()); - uint32_t range_end = ((upload_first_chunk_sent_ or this->tft_size_ < 4096) ? this->tft_size_ : 4096) - 1; + uint32_t range_end = ((this->upload_first_chunk_sent_ || this->tft_size_ < 4096) ? this->tft_size_ : 4096) - 1; ESP_LOGD(TAG, "Range start: %" PRIu32, range_start); - if (range_size <= 0 or range_end <= range_start) { + if (range_size <= 0 || range_end <= range_start) { ESP_LOGE(TAG, "Invalid range end: %" PRIu32 ", size: %" PRIu32, range_end, range_size); return -1; } @@ -34,7 +34,7 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { ESP_LOGV(TAG, "Range: %s", range_header); http_client.addHeader("Range", range_header); int code = http_client.GET(); - if (code != HTTP_CODE_OK and code != HTTP_CODE_PARTIAL_CONTENT) { + if (code != HTTP_CODE_OK && code != HTTP_CODE_PARTIAL_CONTENT) { ESP_LOGW(TAG, "HTTP failed: %s", HTTPClient::errorToString(code).c_str()); return -1; } @@ -80,12 +80,12 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, this->upload_first_chunk_sent_ ? 500 : 5000, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, EspClass::getFreeHeap()); - upload_first_chunk_sent_ = true; + this->upload_first_chunk_sent_ = true; if (recv_string.empty()) { ESP_LOGW(TAG, "No response from display during upload"); allocator.deallocate(buffer, 4096); @@ -112,7 +112,7 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { allocator.deallocate(buffer, 4096); buffer = nullptr; return range_end + 1; - } else if (recv_string[0] != 0x05 and recv_string[0] != 0x08) { // 0x05 == "ok" + } else if (recv_string[0] != 0x05 && recv_string[0] != 0x08) { // 0x05 == "ok" char hex_buf[format_hex_pretty_size(NEXTION_MAX_RESPONSE_LOG_BYTES)]; ESP_LOGE( TAG, "Invalid response: [%s]", @@ -214,7 +214,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { ++tries; } - if (code != 200 and code != 206) { + if (code != 200 && code != 206) { ESP_LOGE(TAG, "HTTP request failed with status %d", code); return this->upload_end_(false); } From 6aafb521c15bf9a873f71392a445b1f7983234ed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:59:21 +0000 Subject: [PATCH 013/160] Bump ruff from 0.15.7 to 0.15.8 (#15192) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e2bfe09ce..f4729f211c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.6 + rev: v0.15.8 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 1440b20333..3b277e214d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.7 # also change in .pre-commit-config.yaml when updating +ruff==0.15.8 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From fa8a609bcc1939ec4f9d7b54dc89b54bfc8ff23a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:50:50 -1000 Subject: [PATCH 014/160] [automation] Eliminate trigger trampolines with deduplicated forwarder structs (#15174) --- esphome/automation.py | 44 +++++++++++ esphome/components/binary_sensor/__init__.py | 61 +++++---------- esphome/components/button/__init__.py | 16 +--- esphome/components/event/__init__.py | 14 +--- esphome/components/number/__init__.py | 14 +--- esphome/components/sensor/__init__.py | 34 +++----- esphome/components/switch/__init__.py | 48 +++--------- esphome/components/text_sensor/__init__.py | 38 +++------ esphome/core/automation.h | 42 +++++++++- tests/unit_tests/test_automation.py | 81 +++++++++++++++++++- 10 files changed, 226 insertions(+), 166 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 17966dc782..7b1d6ceca1 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -137,6 +137,9 @@ UpdateComponentAction = cg.esphome_ns.class_("UpdateComponentAction", Action) SuspendComponentAction = cg.esphome_ns.class_("SuspendComponentAction", Action) ResumeComponentAction = cg.esphome_ns.class_("ResumeComponentAction", Action) Automation = cg.esphome_ns.class_("Automation") +TriggerForwarder = cg.esphome_ns.class_("TriggerForwarder") +TriggerOnTrueForwarder = cg.esphome_ns.class_("TriggerOnTrueForwarder") +TriggerOnFalseForwarder = cg.esphome_ns.class_("TriggerOnFalseForwarder") LambdaCondition = cg.esphome_ns.class_("LambdaCondition", Condition) StatelessLambdaCondition = cg.esphome_ns.class_("StatelessLambdaCondition", Condition) @@ -661,3 +664,44 @@ async def build_automation( actions = await build_action_list(config[CONF_THEN], templ, args) cg.add(obj.add_actions(actions)) return obj + + +async def build_callback_automation( + parent: MockObj, + callback_method: str, + args: TemplateArgsType, + config: ConfigType, + forwarder: MockObj | MockObjClass | None = None, +) -> None: + """Build an Automation and register it as a callback on the parent. + + Eliminates the need for a Trigger wrapper object by registering the + automation's trigger() directly as a callback on the parent component. + + Uses template forwarder structs so the compiler deduplicates the operator() + body across all call sites with the same signature. The forwarder must be + pointer-sized (single Automation* field) to fit inline in Callback::ctx_ + and avoid heap allocation. + + :param parent: The component object (e.g., button, sensor). + :param callback_method: Name of the callback method (e.g., "add_on_press_callback"). + :param args: Automation template args as list of (type, name) tuples. + :param config: The automation config dict. + :param forwarder: Optional forwarder type to use instead of the default + TriggerForwarder. Pass any struct type whose aggregate init takes + a single Automation pointer (e.g., TriggerOnTrueForwarder). + """ + arg_types = [arg[0] for arg in args] + templ = cg.TemplateArguments(*arg_types) + obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ) + actions = await build_action_list(config[CONF_THEN], templ, args) + cg.add(obj.add_actions(actions)) + # Use template forwarder structs for deduplication. The compiler generates + # one operator() per forwarder type; different automation pointers are just + # data in the struct. + if forwarder is None: + forwarder = TriggerForwarder.template(templ) + # RawExpression for aggregate init — both forwarder and obj are codegen + # MockObjs (not user input), and there's no Expression type for positional + # aggregate initialization (StructInitializer uses named fields). + cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}"))) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 37cccc01be..4705f1675d 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -120,10 +120,6 @@ BinarySensorInitiallyOff = binary_sensor_ns.class_( BinarySensorPtr = BinarySensor.operator("ptr") # Triggers -PressTrigger = binary_sensor_ns.class_("PressTrigger", automation.Trigger.template()) -ReleaseTrigger = binary_sensor_ns.class_( - "ReleaseTrigger", automation.Trigger.template() -) ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.template()) DoubleClickTrigger = binary_sensor_ns.class_( "DoubleClickTrigger", automation.Trigger.template() @@ -132,13 +128,6 @@ MultiClickTrigger = binary_sensor_ns.class_( "MultiClickTrigger", automation.Trigger.template(), cg.Component ) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") -StateTrigger = binary_sensor_ns.class_( - "StateTrigger", automation.Trigger.template(bool) -) -StateChangeTrigger = binary_sensor_ns.class_( - "StateChangeTrigger", - automation.Trigger.template(cg.optional.template(bool), cg.optional.template(bool)), -) BinarySensorPublishAction = binary_sensor_ns.class_( "BinarySensorPublishAction", automation.Action @@ -458,16 +447,8 @@ _BINARY_SENSOR_SCHEMA = ( ): cv.boolean, cv.Optional(CONF_DEVICE_CLASS): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, - cv.Optional(CONF_ON_PRESS): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PressTrigger), - } - ), - cv.Optional(CONF_ON_RELEASE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ReleaseTrigger), - } - ), + cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), + cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), cv.Optional(CONF_ON_CLICK): cv.All( automation.validate_automation( { @@ -509,16 +490,8 @@ _BINARY_SENSOR_SCHEMA = ( ): cv.positive_time_period_milliseconds, } ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateTrigger), - } - ), - cv.Optional(CONF_ON_STATE_CHANGE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateChangeTrigger), - } - ), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + cv.Optional(CONF_ON_STATE_CHANGE): automation.validate_automation({}), } ) ) @@ -556,13 +529,14 @@ def binary_sensor_schema( @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_binary_sensor_automations(var, config): - for conf in config.get(CONF_ON_PRESS, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - - for conf in config.get(CONF_ON_RELEASE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + for conf_key, forwarder in ( + (CONF_ON_PRESS, automation.TriggerOnTrueForwarder), + (CONF_ON_RELEASE, automation.TriggerOnFalseForwarder), + ): + for conf in config.get(conf_key, []): + await automation.build_callback_automation( + var, "add_on_state_callback", [], conf, forwarder=forwarder + ) for conf in config.get(CONF_ON_CLICK, []): trigger = cg.new_Pvariable( @@ -593,13 +567,14 @@ async def _build_binary_sensor_automations(var, config): await automation.build_automation(trigger, [], conf) for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(bool, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(bool, "x")], conf + ) for conf in config.get(CONF_ON_STATE_CHANGE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, + await automation.build_callback_automation( + var, + "add_full_state_callback", [ (cg.optional.template(bool), "x_previous"), (cg.optional.template(bool), "x"), diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index 12d9ebaba6..f279b6ffe3 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_ID, CONF_MQTT_ID, CONF_ON_PRESS, - CONF_TRIGGER_ID, CONF_WEB_SERVER, DEVICE_CLASS_EMPTY, DEVICE_CLASS_IDENTIFY, @@ -41,10 +40,6 @@ ButtonPtr = Button.operator("ptr") PressAction = button_ns.class_("PressAction", automation.Action) -ButtonPressTrigger = button_ns.class_( - "ButtonPressTrigger", automation.Trigger.template() -) - validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") @@ -55,11 +50,7 @@ _BUTTON_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent), cv.Optional(CONF_DEVICE_CLASS): validate_device_class, - cv.Optional(CONF_ON_PRESS): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ButtonPressTrigger), - } - ), + cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), } ) ) @@ -91,8 +82,9 @@ def button_schema( @setup_entity("button") async def setup_button_core_(var, config): for conf in config.get(CONF_ON_PRESS, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_press_callback", [], conf + ) setup_device_class(config) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 300902b8ca..527bb4ebba 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_ID, CONF_MQTT_ID, CONF_ON_EVENT, - CONF_TRIGGER_ID, CONF_WEB_SERVER, DEVICE_CLASS_BUTTON, DEVICE_CLASS_DOORBELL, @@ -41,8 +40,6 @@ EventPtr = Event.operator("ptr") TriggerEventAction = event_ns.class_("TriggerEventAction", automation.Action) -EventTrigger = event_ns.class_("EventTrigger", automation.Trigger.template()) - validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") _EVENT_SCHEMA = ( @@ -53,11 +50,7 @@ _EVENT_SCHEMA = ( cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent), cv.GenerateID(): cv.declare_id(Event), cv.Optional(CONF_DEVICE_CLASS): validate_device_class, - cv.Optional(CONF_ON_EVENT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(EventTrigger), - } - ), + cv.Optional(CONF_ON_EVENT): automation.validate_automation({}), } ) ) @@ -92,8 +85,9 @@ def event_schema( @setup_entity("event") async def setup_event_core_(var, config, *, event_types: list[str]): for conf in config.get(CONF_ON_EVENT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.StringRef, "event_type")], conf) + await automation.build_callback_automation( + var, "add_on_event_callback", [(cg.StringRef, "event_type")], conf + ) cg.add(var.set_event_types(event_types)) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 0570ac0b1e..90f9fe1835 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -155,9 +155,6 @@ Number = number_ns.class_("Number", cg.EntityBase) NumberPtr = Number.operator("ptr") # Triggers -NumberStateTrigger = number_ns.class_( - "NumberStateTrigger", automation.Trigger.template(cg.float_) -) ValueRangeTrigger = number_ns.class_( "ValueRangeTrigger", automation.Trigger.template(cg.float_), cg.Component ) @@ -198,11 +195,7 @@ _NUMBER_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTNumberComponent), - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(NumberStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ValueRangeTrigger), @@ -248,8 +241,9 @@ def number_schema( @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_number_automations(var, config): for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(float, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(float, "x")], conf + ) for conf in config.get(CONF_ON_VALUE_RANGE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await cg.register_component(trigger, conf) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 9f3c1484b0..19d03a0afc 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -238,12 +238,6 @@ Sensor = sensor_ns.class_("Sensor", cg.EntityBase) SensorPtr = Sensor.operator("ptr") # Triggers -SensorStateTrigger = sensor_ns.class_( - "SensorStateTrigger", automation.Trigger.template(cg.float_) -) -SensorRawStateTrigger = sensor_ns.class_( - "SensorRawStateTrigger", automation.Trigger.template(cg.float_) -) ValueRangeTrigger = sensor_ns.class_( "ValueRangeTrigger", automation.Trigger.template(cg.float_), cg.Component ) @@ -316,18 +310,8 @@ _SENSOR_SCHEMA = ( cv.Any(None, cv.positive_time_period_milliseconds), ), cv.Optional(CONF_FILTERS): validate_filters, - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SensorStateTrigger), - } - ), - cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - SensorRawStateTrigger - ), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), + cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ValueRangeTrigger), @@ -897,12 +881,14 @@ async def build_filters(config): @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_sensor_automations(var, config): - for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(float, "x")], conf) - for conf in config.get(CONF_ON_RAW_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(float, "x")], conf) + for conf_key, callback in ( + (CONF_ON_VALUE, "add_on_state_callback"), + (CONF_ON_RAW_VALUE, "add_on_raw_state_callback"), + ): + for conf in config.get(conf_key, []): + await automation.build_callback_automation( + var, callback, [(float, "x")], conf + ) for conf in config.get(CONF_ON_VALUE_RANGE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await cg.register_component(trigger, conf) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index bbafc54bd1..c4dd4856e3 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_ON_TURN_ON, CONF_RESTORE_MODE, CONF_STATE, - CONF_TRIGGER_ID, CONF_WEB_SERVER, DEVICE_CLASS_EMPTY, DEVICE_CLASS_OUTLET, @@ -61,17 +60,6 @@ TurnOnAction = switch_ns.class_("TurnOnAction", automation.Action) SwitchPublishAction = switch_ns.class_("SwitchPublishAction", automation.Action) SwitchCondition = switch_ns.class_("SwitchCondition", Condition) -SwitchStateTrigger = switch_ns.class_( - "SwitchStateTrigger", automation.Trigger.template(bool) -) -SwitchTurnOnTrigger = switch_ns.class_( - "SwitchTurnOnTrigger", automation.Trigger.template() -) -SwitchTurnOffTrigger = switch_ns.class_( - "SwitchTurnOffTrigger", automation.Trigger.template() -) - - validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True) @@ -86,21 +74,9 @@ _SWITCH_SCHEMA = ( cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum( RESTORE_MODES, upper=True, space="_" ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SwitchStateTrigger), - } - ), - cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SwitchTurnOnTrigger), - } - ), - cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SwitchTurnOffTrigger), - } - ), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}), + cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}), cv.Optional(CONF_DEVICE_CLASS): validate_device_class, } ) @@ -147,15 +123,15 @@ def switch_schema( @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_switch_automations(var, config): - for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(bool, "x")], conf) - for conf in config.get(CONF_ON_TURN_ON, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_TURN_OFF, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + for conf_key, args, forwarder in ( + (CONF_ON_STATE, [(bool, "x")], None), + (CONF_ON_TURN_ON, [], automation.TriggerOnTrueForwarder), + (CONF_ON_TURN_OFF, [], automation.TriggerOnFalseForwarder), + ): + for conf in config.get(conf_key, []): + await automation.build_callback_automation( + var, "add_on_state_callback", args, conf, forwarder=forwarder + ) @setup_entity("switch") diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 97f394ecf7..51eedf9a95 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_ON_VALUE, CONF_STATE, CONF_TO, - CONF_TRIGGER_ID, CONF_WEB_SERVER, DEVICE_CLASS_DATE, DEVICE_CLASS_EMPTY, @@ -42,12 +41,6 @@ text_sensor_ns = cg.esphome_ns.namespace("text_sensor") TextSensor = text_sensor_ns.class_("TextSensor", cg.EntityBase) TextSensorPtr = TextSensor.operator("ptr") -TextSensorStateTrigger = text_sensor_ns.class_( - "TextSensorStateTrigger", automation.Trigger.template(cg.std_string) -) -TextSensorStateRawTrigger = text_sensor_ns.class_( - "TextSensorStateRawTrigger", automation.Trigger.template(cg.std_string) -) TextSensorPublishAction = text_sensor_ns.class_( "TextSensorPublishAction", automation.Action ) @@ -150,20 +143,8 @@ _TEXT_SENSOR_SCHEMA = ( cv.GenerateID(): cv.declare_id(TextSensor), cv.Optional(CONF_DEVICE_CLASS): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - TextSensorStateTrigger - ), - } - ), - cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - TextSensorStateRawTrigger - ), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), + cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), } ) ) @@ -203,13 +184,14 @@ async def build_filters(config): @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_text_sensor_automations(var, config): - for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) - - for conf in config.get(CONF_ON_RAW_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + for conf_key, callback in ( + (CONF_ON_VALUE, "add_on_state_callback"), + (CONF_ON_RAW_VALUE, "add_on_raw_state_callback"), + ): + for conf in config.get(conf_key, []): + await automation.build_callback_automation( + var, callback, [(cg.std_string, "x")], conf + ) @setup_entity("text_sensor") diff --git a/esphome/core/automation.h b/esphome/core/automation.h index ca4a2c8b6b..fc2cad99be 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -470,7 +470,9 @@ template class ActionList { template class Automation { public: - explicit Automation(Trigger *trigger) : trigger_(trigger) { this->trigger_->set_automation_parent(this); } + /// Default constructor for use with TriggerForwarder (no Trigger object needed). + Automation() = default; + explicit Automation(Trigger *trigger) { trigger->set_automation_parent(this); } void add_action(Action *action) { this->actions_.add_action(action); } void add_actions(const std::initializer_list *> &actions) { this->actions_.add_actions(actions); } @@ -487,8 +489,44 @@ template class Automation { int num_running() { return this->actions_.num_running(); } protected: - Trigger *trigger_; ActionList actions_; }; +/// Callback forwarder that triggers an Automation directly. +/// One operator() instantiation per Automation signature, shared across all call sites. +/// Must stay pointer-sized to fit inline in Callback::ctx_ without heap allocation. +template struct TriggerForwarder { + Automation *automation; + void operator()(const Ts &...args) const { this->automation->trigger(args...); } +}; + +/// Callback forwarder that triggers an Automation<> only when the bool arg is true. +/// Must stay pointer-sized to fit inline in Callback::ctx_ without heap allocation. +struct TriggerOnTrueForwarder { + Automation<> *automation; + void operator()(bool state) const { + if (state) + this->automation->trigger(); + } +}; + +/// Callback forwarder that triggers an Automation<> only when the bool arg is false. +/// Must stay pointer-sized to fit inline in Callback::ctx_ without heap allocation. +struct TriggerOnFalseForwarder { + Automation<> *automation; + void operator()(bool state) const { + if (!state) + this->automation->trigger(); + } +}; + +// Ensure forwarders fit in Callback::ctx_ (pointer-sized inline storage). +// If these fail, the forwarder would heap-allocate in Callback::create(). +static_assert(sizeof(TriggerForwarder<>) <= sizeof(void *)); +static_assert(sizeof(TriggerOnTrueForwarder) <= sizeof(void *)); +static_assert(sizeof(TriggerOnFalseForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); + } // namespace esphome diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 61fef8201d..37779f23e6 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -5,7 +5,13 @@ from unittest.mock import patch import pytest -from esphome.automation import has_non_synchronous_actions +from esphome.automation import ( + TriggerForwarder, + TriggerOnFalseForwarder, + TriggerOnTrueForwarder, + has_non_synchronous_actions, +) +from esphome.cpp_generator import MockObj, RawExpression from esphome.util import RegistryEntry @@ -175,3 +181,76 @@ def test_has_non_synchronous_actions_dict_input( """Direct dict input (single action).""" assert has_non_synchronous_actions({"delay": "1s"}) is True assert has_non_synchronous_actions({"logger.log": "hello"}) is False + + +def _build_forwarder( + automation_name: str, + args: list[tuple[str, str]], + forwarder: MockObj | None = None, +) -> str: + """Build a trigger forwarder expression the same way build_callback_automation does. + + Mirrors the forwarder selection logic in automation.build_callback_automation. + """ + import esphome.codegen as cg + + obj = MockObj(automation_name, "->") + if forwarder is None: + arg_types = [RawExpression(t) for t, _ in args] + templ = ( + cg.TemplateArguments(*arg_types) if arg_types else cg.TemplateArguments() + ) + forwarder = TriggerForwarder.template(templ) + return f"{forwarder}{{{obj}}}" + + +def test_trigger_forwarder_no_args() -> None: + """Button on_press: TriggerForwarder<> with no args.""" + result = _build_forwarder("auto_1", []) + assert result == "TriggerForwarder<>{auto_1}" + + +def test_trigger_forwarder_single_float_arg() -> None: + """Sensor on_value: TriggerForwarder.""" + result = _build_forwarder("auto_1", [("float", "x")]) + assert result == "TriggerForwarder{auto_1}" + + +def test_trigger_forwarder_single_bool_arg() -> None: + """Switch on_state: TriggerForwarder.""" + result = _build_forwarder("auto_1", [("bool", "x")]) + assert result == "TriggerForwarder{auto_1}" + + +def test_trigger_forwarder_on_true() -> None: + """Binary_sensor on_press / switch on_turn_on: TriggerOnTrueForwarder.""" + result = _build_forwarder("auto_1", [], forwarder=TriggerOnTrueForwarder) + assert result == "TriggerOnTrueForwarder{auto_1}" + + +def test_trigger_forwarder_on_false() -> None: + """Binary_sensor on_release / switch on_turn_off: TriggerOnFalseForwarder.""" + result = _build_forwarder("auto_1", [], forwarder=TriggerOnFalseForwarder) + assert result == "TriggerOnFalseForwarder{auto_1}" + + +def test_trigger_forwarder_multiple_args() -> None: + """Binary_sensor on_state_change: TriggerForwarder with two args.""" + result = _build_forwarder( + "auto_1", + [("optional", "x_previous"), ("optional", "x")], + ) + assert result == "TriggerForwarder, optional>{auto_1}" + + +def test_trigger_forwarder_string_arg() -> None: + """Text_sensor on_value: TriggerForwarder.""" + result = _build_forwarder("auto_1", [("std::string", "x")]) + assert result == "TriggerForwarder{auto_1}" + + +def test_trigger_forwarder_custom_type() -> None: + """Custom forwarder type passed directly.""" + custom = MockObj("MyForwarder", "") + result = _build_forwarder("auto_1", [], forwarder=custom) + assert result == "MyForwarder{auto_1}" From 240e53afce87155d12fe72a4f31117abe7ee4a13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 14:35:09 -1000 Subject: [PATCH 015/160] [fan] Add benchmarks for fan component (#15210) --- tests/benchmarks/components/fan/__init__.py | 5 + tests/benchmarks/components/fan/bench_fan.cpp | 122 ++++++++++++++++++ .../benchmarks/components/fan/benchmark.yaml | 1 + 3 files changed, 128 insertions(+) create mode 100644 tests/benchmarks/components/fan/__init__.py create mode 100644 tests/benchmarks/components/fan/bench_fan.cpp create mode 100644 tests/benchmarks/components/fan/benchmark.yaml diff --git a/tests/benchmarks/components/fan/__init__.py b/tests/benchmarks/components/fan/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/fan/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/fan/bench_fan.cpp b/tests/benchmarks/components/fan/bench_fan.cpp new file mode 100644 index 0000000000..c7966c7886 --- /dev/null +++ b/tests/benchmarks/components/fan/bench_fan.cpp @@ -0,0 +1,122 @@ +#include + +#include "esphome/components/fan/fan.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Fan for benchmarking — control() is a no-op. +class BenchFan : public fan::Fan { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + fan::FanTraits get_traits() override { return this->traits_; } + + fan::FanTraits traits_; + + protected: + void control(const fan::FanCall & /*call*/) override {} +}; + +// Helper to create a typical fan device for benchmarks. +// Note: setup() is not called (no preferences backend), so save_state_() +// is effectively a no-op. This benchmarks the call/validation path, not persistence. +static void setup_fan(BenchFan &fan) { + fan.configure("test_fan"); + fan.traits_.set_oscillation(true); + fan.traits_.set_speed(true); + fan.traits_.set_supported_speed_count(6); + fan.traits_.set_direction(true); + fan.set_restore_mode(fan::FanRestoreMode::NO_RESTORE); + fan.traits_.set_supported_preset_modes({ + "auto", + "sleep", + "nature", + "turbo", + }); +} + +// --- Fan::publish_state() with speed update --- +// Measures the publish path for a fan reporting state — +// the hot path during fan operation. + +static void FanPublish_State(benchmark::State &state) { + BenchFan fan; + setup_fan(fan); + fan.state = true; + fan.direction = fan::FanDirection::FORWARD; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + fan.speed = (i % 6) + 1; + fan.publish_state(); + } + benchmark::DoNotOptimize(fan.speed); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FanPublish_State); + +// --- Fan::publish_state() with callback --- +// Measures callback dispatch overhead. + +static void FanPublish_WithCallback(benchmark::State &state) { + BenchFan fan; + setup_fan(fan); + fan.state = true; + + uint64_t callback_count = 0; + fan.add_on_state_callback([&callback_count]() { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + fan.speed = (i % 6) + 1; + fan.publish_state(); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FanPublish_WithCallback); + +// --- FanCall::perform() set speed --- +// The most common fan call — adjusting the speed level. + +static void FanCall_SetSpeed(benchmark::State &state) { + BenchFan fan; + setup_fan(fan); + fan.state = true; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + int speed = (i % 6) + 1; + fan.make_call().set_speed(speed).perform(); + } + benchmark::DoNotOptimize(fan.speed); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FanCall_SetSpeed); + +// --- FanCall::perform() with multiple fields --- +// Exercises the validation path with state, speed, oscillation, and direction. + +static void FanCall_MultiField(benchmark::State &state) { + BenchFan fan; + setup_fan(fan); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + auto dir = (i % 2 == 0) ? fan::FanDirection::FORWARD : fan::FanDirection::REVERSE; + int speed = (i % 6) + 1; + fan.make_call().set_state(true).set_speed(speed).set_oscillating(i % 2 == 0).set_direction(dir).perform(); + } + benchmark::DoNotOptimize(fan.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FanCall_MultiField); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/fan/benchmark.yaml b/tests/benchmarks/components/fan/benchmark.yaml new file mode 100644 index 0000000000..e9d59c12b2 --- /dev/null +++ b/tests/benchmarks/components/fan/benchmark.yaml @@ -0,0 +1 @@ +fan: From 90e6c0d7c7b2174309cd5309e0e082b6526b37e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:09:16 -1000 Subject: [PATCH 016/160] [core] Remove indirection from ControllerRegistry dispatch (#15173) --- esphome/core/controller_registry.cpp | 22 +++++++++++----------- esphome/core/controller_registry.h | 19 ++----------------- 2 files changed, 13 insertions(+), 28 deletions(-) diff --git a/esphome/core/controller_registry.cpp b/esphome/core/controller_registry.cpp index 255efa86ba..dd69de47d4 100644 --- a/esphome/core/controller_registry.cpp +++ b/esphome/core/controller_registry.cpp @@ -10,24 +10,24 @@ StaticVector ControllerRegistry::controll void ControllerRegistry::register_controller(Controller *controller) { controllers.push_back(controller); } -void ControllerRegistry::notify(void *obj, DispatchFunc dispatch) { - for (auto *controller : controllers) { - dispatch(controller, obj); - } -} - -// Macro for standard registry notification dispatch - calls on__update() -// Each wrapper passes a small trampoline lambda that calls the correct virtual method. +// Each notify method directly iterates controllers and calls the virtual method. +// This avoids the overhead of a shared noinline dispatch loop with function pointer +// indirection. The loop is tiny (~20 bytes per entity type) so the flash cost of +// duplicating it is negligible compared to eliminating two levels of indirection +// (noinline call + function pointer) from every state publish. // NOLINTBEGIN(bugprone-macro-parentheses) #define CONTROLLER_REGISTRY_NOTIFY(entity_type, entity_name) \ void ControllerRegistry::notify_##entity_name##_update(entity_type *obj) { \ - notify(obj, [](Controller *c, void *o) { c->on_##entity_name##_update(static_cast(o)); }); \ + for (auto *controller : controllers) { \ + controller->on_##entity_name##_update(obj); \ + } \ } -// Macro for entities where controller method has no "_update" suffix (Event, Update) #define CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(entity_type, entity_name) \ void ControllerRegistry::notify_##entity_name(entity_type *obj) { \ - notify(obj, [](Controller *c, void *o) { c->on_##entity_name(static_cast(o)); }); \ + for (auto *controller : controllers) { \ + controller->on_##entity_name(obj); \ + } \ } // NOLINTEND(bugprone-macro-parentheses) diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index 15e3b4ba83..89b3069bcb 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -146,8 +146,8 @@ class UpdateEntity; * entities call ControllerRegistry::notify_*_update() which iterates the small list * of registered controllers (typically 2: API and WebServer). * - * Controllers read state directly from entities using existing accessors (obj->state, etc.) - * rather than receiving it as callback parameters that were being ignored anyway. + * Each notify method directly iterates controllers and calls the virtual method, + * avoiding function pointer indirection for minimal dispatch overhead. * * Memory savings: 32 bytes per entity (2 controllers × 16 bytes std::function overhead) * Typical config (25 entities): ~780 bytes saved @@ -247,21 +247,6 @@ class ControllerRegistry { #endif protected: - /** Type-erased dispatch function pointer. - * - * Each notify method passes a small trampoline that calls the - * correct virtual method on Controller. The shared notify() loop - * iterates controllers once, calling the trampoline for each. - */ - using DispatchFunc = void (*)(Controller *, void *); - - /** Shared dispatch loop - iterates controllers and calls dispatch for each. - * - * Marked noinline to ensure only one copy of the loop exists in flash, - * rather than being duplicated into each notify_*_update wrapper. - */ - static void __attribute__((noinline)) notify(void *obj, DispatchFunc dispatch); - static StaticVector controllers; }; From e77cdb59710c5db3a904a6054ebc63035954d40e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:13:44 -1000 Subject: [PATCH 017/160] [light] Validate effect names during config validation instead of codegen (#15107) --- esphome/components/light/__init__.py | 75 +++++ esphome/components/light/automation.py | 49 ++- tests/component_tests/light/__init__.py | 0 .../light/test_effect_validation.py | 280 ++++++++++++++++++ 4 files changed, 395 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/light/__init__.py create mode 100644 tests/component_tests/light/test_effect_validation.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 4090ca57c2..5925afb472 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -24,6 +24,7 @@ from esphome.const import ( CONF_ID, CONF_INITIAL_STATE, CONF_MQTT_ID, + CONF_NAME, CONF_ON_STATE, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, @@ -41,6 +42,8 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, HexInt, Lambda, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass +import esphome.final_validate as fv +from esphome.types import ConfigType from .automation import LIGHT_STATE_SCHEMA from .effects import ( @@ -70,9 +73,19 @@ IS_PLATFORM_COMPONENT = True DOMAIN = "light" +@dataclass +class EffectRef: + """A pending effect name reference from a light action to validate.""" + + light_id: ID + effect_name: str + component_path: list[str | int] # path_context when the action was validated + + @dataclass class LightData: gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr + effect_refs: list[EffectRef] = field(default_factory=list) def _get_data() -> LightData: @@ -115,6 +128,68 @@ def _get_or_create_gamma_table(gamma_correct): return fwd_arr +def find_effect_index(effects: list, effect_name: str) -> int | None: + """Find the 1-based index of an effect by name (case-insensitive). + + Returns the 1-based index if found, or None if not found. + """ + effect_name_lower = effect_name.lower() + for i, effect_conf in enumerate(effects): + key = next(iter(effect_conf)) + if effect_conf[key][CONF_NAME].lower() == effect_name_lower: + return i + 1 + return None + + +def available_effects_str(effects: list) -> str: + """Return a comma-separated string of available effect names.""" + available = [ + effect_conf[next(iter(effect_conf))][CONF_NAME] for effect_conf in effects + ] + return ", ".join(f"'{name}'" for name in available) if available else "none" + + +def _final_validate(config: ConfigType) -> ConfigType: + """Validate all recorded effect name references against their target lights. + + This runs once per light platform instance. If no light platform is configured, + this never runs — but the ID validator will catch the missing light ID separately. + """ + data = _get_data() + if not data.effect_refs: + return config + + # Drain the list so we only validate once even though + # FINAL_VALIDATE_SCHEMA runs for each light platform instance. + refs = data.effect_refs + data.effect_refs = [] + + fconf = fv.full_config.get() + + for ref in refs: + try: + light_path = fconf.get_path_for_id(ref.light_id)[:-1] + light_config = fconf.get_config_for_path(light_path) + except KeyError: + # Light ID not found — ID validation will have already reported this + continue + + effects = light_config.get(CONF_EFFECTS, []) + + if find_effect_index(effects, ref.effect_name) is None: + raise cv.FinalExternalInvalid( + f"Effect '{ref.effect_name}' not found for light " + f"'{ref.light_id}'. " + f"Available effects: {available_effects_str(effects)}", + path=[cv.ROOT_CONFIG_PATH] + ref.component_path, + ) + + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + LightRestoreMode = light_ns.enum("LightRestoreMode") RESTORE_MODES = { "RESTORE_DEFAULT_OFF": LightRestoreMode.LIGHT_RESTORE_DEFAULT_OFF, diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 55273003b9..16e7d72f6b 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -1,5 +1,6 @@ from esphome import automation import esphome.codegen as cg +from esphome.config import path_context import esphome.config_validation as cv from esphome.const import ( CONF_BLUE, @@ -17,7 +18,6 @@ from esphome.const import ( CONF_LIMIT_MODE, CONF_MAX_BRIGHTNESS, CONF_MIN_BRIGHTNESS, - CONF_NAME, CONF_RANGE_FROM, CONF_RANGE_TO, CONF_RED, @@ -26,7 +26,7 @@ from esphome.const import ( CONF_WARM_WHITE, CONF_WHITE, ) -from esphome.core import CORE, Lambda +from esphome.core import CORE, EsphomeError, Lambda from esphome.cpp_generator import LambdaExpression from esphome.types import ConfigType @@ -98,6 +98,31 @@ LIGHT_CONTROL_ACTION_SCHEMA = LIGHT_STATE_SCHEMA.extend( } ) + +def _record_effect_ref(config: ConfigType) -> ConfigType: + """Record a static effect name reference for later cross-component validation.""" + if CONF_EFFECT not in config: + return config + effect = config[CONF_EFFECT] + if isinstance(effect, Lambda): + return config # Lambda effects resolved at runtime + if effect.lower() == "none": + return config # "None" is always valid + + from . import EffectRef, _get_data + + _get_data().effect_refs.append( + EffectRef( + light_id=config[CONF_ID], + effect_name=effect, + component_path=path_context.get(), + ) + ) + return config + + +LIGHT_CONTROL_ACTION_SCHEMA.add_extra(_record_effect_ref) + LIGHT_TURN_OFF_ACTION_SCHEMA = automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(LightState), @@ -122,18 +147,24 @@ def _resolve_effect_index(config: ConfigType) -> int: Effect index 0 means "None" (no effect). Effects are 1-indexed matching the C++ convention in LightState. """ + from . import available_effects_str, find_effect_index + original_name = config[CONF_EFFECT] - effect_name = original_name.lower() - if effect_name == "none": + if original_name.lower() == "none": return 0 light_id = config[CONF_ID] light_path = CORE.config.get_path_for_id(light_id)[:-1] light_config = CORE.config.get_config_for_path(light_path) - for i, effect_conf in enumerate(light_config.get(CONF_EFFECTS, [])): - key = next(iter(effect_conf)) - if effect_conf[key][CONF_NAME].lower() == effect_name: - return i + 1 - raise ValueError(f"Effect '{original_name}' not found in light '{light_id}'") + effects = light_config.get(CONF_EFFECTS, []) + index = find_effect_index(effects, original_name) + if index is not None: + return index + # Should never reach here — effect names are validated during config + # validation in FINAL_VALIDATE_SCHEMA. This is a safety net. + raise EsphomeError( + f"Effect '{original_name}' not found for light '{light_id}'. " + f"Available effects: {available_effects_str(effects)}" + ) @automation.register_action( diff --git a/tests/component_tests/light/__init__.py b/tests/component_tests/light/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/light/test_effect_validation.py b/tests/component_tests/light/test_effect_validation.py new file mode 100644 index 0000000000..579e92c62a --- /dev/null +++ b/tests/component_tests/light/test_effect_validation.py @@ -0,0 +1,280 @@ +"""Tests for light effect name validation.""" + +from __future__ import annotations + +from collections.abc import Generator +from contextvars import Token + +import pytest + +from esphome import config_validation as cv +from esphome.components.light import ( + EffectRef, + _final_validate, + _get_data, + available_effects_str, + find_effect_index, +) +from esphome.components.light.automation import _record_effect_ref +from esphome.config import Config, path_context +from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME +from esphome.core import ID, Lambda +import esphome.final_validate as fv +from esphome.types import ConfigType + + +def _make_effects(*names: str) -> list[dict[str, dict[str, str]]]: + """Create a list of effect config dicts from names.""" + return [{f"effect_{i}": {CONF_NAME: name}} for i, name in enumerate(names)] + + +# --- find_effect_index --- + + +def test_find_effect_index_found() -> None: + effects = _make_effects("Fast Pulse", "Slow Pulse") + assert find_effect_index(effects, "Fast Pulse") == 1 + assert find_effect_index(effects, "Slow Pulse") == 2 + + +def test_find_effect_index_case_insensitive() -> None: + effects = _make_effects("Fast Pulse") + assert find_effect_index(effects, "fast pulse") == 1 + assert find_effect_index(effects, "FAST PULSE") == 1 + + +def test_find_effect_index_not_found() -> None: + effects = _make_effects("Fast Pulse", "Slow Pulse") + assert find_effect_index(effects, "Missing") is None + + +def test_find_effect_index_empty() -> None: + assert find_effect_index([], "anything") is None + + +# --- available_effects_str --- + + +def test_available_effects_str_multiple() -> None: + effects = _make_effects("Fast Pulse", "Slow Pulse") + assert available_effects_str(effects) == "'Fast Pulse', 'Slow Pulse'" + + +def test_available_effects_str_single() -> None: + effects = _make_effects("Fast Pulse") + assert available_effects_str(effects) == "'Fast Pulse'" + + +def test_available_effects_str_empty() -> None: + assert available_effects_str([]) == "none" + + +# --- _final_validate --- + + +def _setup_final_validate( + effect_refs: list[EffectRef], + light_configs: list[ConfigType], + declare_ids: list[tuple[ID, list[str | int]]], +) -> Token: + """Set up CORE.data and fv.full_config for _final_validate tests.""" + data = _get_data() + data.effect_refs = effect_refs + + full_conf = Config() + full_conf["light"] = light_configs + for id_, path in declare_ids: + full_conf.declare_ids.append((id_, path)) + + return fv.full_config.set(full_conf) + + +def test_final_validate_valid_effect() -> None: + """Valid effect name should not raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_final_validate( + effect_refs=[ + EffectRef( + light_id=light_id, effect_name="Fast Pulse", component_path=["esphome"] + ), + ], + light_configs=[ + {CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse", "Slow Pulse")} + ], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_invalid_effect_raises() -> None: + """Invalid effect name should raise FinalExternalInvalid.""" + light_id = ID("led1", is_declaration=True) + token = _setup_final_validate( + effect_refs=[ + EffectRef( + light_id=light_id, effect_name="Nonexistent", component_path=["esphome"] + ), + ], + light_configs=[ + {CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse", "Slow Pulse")} + ], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="Nonexistent"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_lists_available_effects() -> None: + """Error message should list available effects.""" + light_id = ID("led1", is_declaration=True) + token = _setup_final_validate( + effect_refs=[ + EffectRef( + light_id=light_id, effect_name="Missing", component_path=["esphome"] + ), + ], + light_configs=[ + {CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse", "Slow Pulse")} + ], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="'Fast Pulse', 'Slow Pulse'"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_no_effects_on_light() -> None: + """Light with no effects should report 'none' as available.""" + light_id = ID("led1", is_declaration=True) + token = _setup_final_validate( + effect_refs=[ + EffectRef( + light_id=light_id, effect_name="Missing", component_path=["esphome"] + ), + ], + light_configs=[{CONF_ID: light_id}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="Available effects: none"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_no_refs_is_noop() -> None: + """No stored refs should pass without error.""" + data = _get_data() + data.effect_refs = [] + _final_validate({}) + + +def test_final_validate_unknown_light_id_skipped() -> None: + """Refs to unknown light IDs should be silently skipped.""" + data = _get_data() + data.effect_refs = [ + EffectRef( + light_id=ID("nonexistent", is_declaration=True), + effect_name="Missing", + component_path=["esphome"], + ) + ] + + full_conf = Config() + token = fv.full_config.set(full_conf) + try: + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_drains_refs() -> None: + """Refs should be drained after validation to avoid redundant runs.""" + light_id = ID("led1", is_declaration=True) + token = _setup_final_validate( + effect_refs=[ + EffectRef( + light_id=light_id, effect_name="Fast Pulse", component_path=["esphome"] + ), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + _final_validate({}) + assert _get_data().effect_refs == [] + finally: + fv.full_config.reset(token) + + +# --- _record_effect_ref --- + + +@pytest.fixture +def _path_ctx() -> Generator[None]: + """Set path_context for _record_effect_ref tests.""" + token = path_context.set(["esphome"]) + yield + path_context.reset(token) + + +@pytest.mark.usefixtures("_path_ctx") +def test_record_effect_ref_static() -> None: + """Static effect name should be recorded.""" + light_id = ID("led1", is_declaration=True) + config: ConfigType = {CONF_ID: light_id, CONF_EFFECT: "Fast Pulse"} + result = _record_effect_ref(config) + assert result is config + data = _get_data() + assert len(data.effect_refs) == 1 + assert data.effect_refs[0].effect_name == "Fast Pulse" + assert data.effect_refs[0].light_id is light_id + assert data.effect_refs[0].component_path == ["esphome"] + + +@pytest.mark.usefixtures("_path_ctx") +def test_record_effect_ref_skips_lambda() -> None: + """Lambda effect should not be recorded.""" + config: ConfigType = { + CONF_ID: ID("led1", is_declaration=True), + CONF_EFFECT: Lambda("return effect;"), + } + _record_effect_ref(config) + assert _get_data().effect_refs == [] + + +@pytest.mark.usefixtures("_path_ctx") +def test_record_effect_ref_skips_none() -> None: + """Effect 'None' should not be recorded.""" + config: ConfigType = { + CONF_ID: ID("led1", is_declaration=True), + CONF_EFFECT: "None", + } + _record_effect_ref(config) + assert _get_data().effect_refs == [] + + +@pytest.mark.usefixtures("_path_ctx") +def test_record_effect_ref_skips_none_case_insensitive() -> None: + """Effect 'none' (lowercase) should not be recorded.""" + config: ConfigType = { + CONF_ID: ID("led1", is_declaration=True), + CONF_EFFECT: "none", + } + _record_effect_ref(config) + assert _get_data().effect_refs == [] + + +def test_record_effect_ref_skips_no_effect_key() -> None: + """Config without effect key should be a no-op.""" + config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)} + _record_effect_ref(config) + assert _get_data().effect_refs == [] From 90dafa3fa45bc5b279136f069030e1ea4edde780 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:59:58 -1000 Subject: [PATCH 018/160] [logger] Warn when VERBOSE/VERY_VERBOSE logging is active (#15189) --- esphome/components/logger/logger.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index cd6543bfb8..23b69c36c6 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -243,6 +243,16 @@ void Logger::dump_config() { #endif #ifdef USE_ZEPHYR dump_crash_(); +#endif + // Warn users that VERBOSE/VERY_VERBOSE logging impacts performance. + // Only the compiled log level matters — all log calls up to this level + // are in the binary and will be formatted (vsnprintf) and block UART. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + ESP_LOGW(TAG, "VERY_VERBOSE logging is active — significant performance impact, short-term debugging only\n" + " May cause connection instability. Set log level to DEBUG or lower for long-term use."); +#elif ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGI(TAG, "VERBOSE logging is active — performance impact, short-term debugging only\n" + " Set log level to DEBUG or lower for long-term use."); #endif } From 6feb2d04dfd61c3fe1d03980fe1516b846eacf6b Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 27 Mar 2026 04:36:35 +0100 Subject: [PATCH 019/160] [nextion] Replace `static std::string COMMAND_DELIMITER` with `constexpr` (#15195) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/nextion/nextion.cpp | 13 +++++++++---- esphome/components/nextion/nextion.h | 2 -- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 612bfbc968..fa1582c209 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -10,6 +10,10 @@ namespace nextion { static const char *const TAG = "nextion"; +// Nextion command terminator: three consecutive 0xFF bytes (per Nextion Instruction Set v1.1). +static constexpr uint8_t COMMAND_DELIMITER[3] = {0xFF, 0xFF, 0xFF}; +static constexpr size_t DELIMITER_SIZE = sizeof(COMMAND_DELIMITER); + void Nextion::setup() { this->is_setup_ = false; this->connection_state_.ignore_is_setup_ = true; @@ -415,7 +419,8 @@ void Nextion::process_nextion_commands_() { #ifdef NEXTION_PROTOCOL_LOG this->print_queue_members_(); #endif - while ((to_process_length = this->command_data_.find(COMMAND_DELIMITER)) != std::string::npos) { + while ((to_process_length = this->command_data_.find(reinterpret_cast(COMMAND_DELIMITER), 0, + DELIMITER_SIZE)) != std::string::npos) { #ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP if (++commands_processed > this->max_commands_per_loop_) { ESP_LOGW(TAG, "Command processing limit exceeded"); @@ -423,8 +428,8 @@ void Nextion::process_nextion_commands_() { } #endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP ESP_LOGN(TAG, "queue size: %zu", this->nextion_queue_.size()); - while (to_process_length + COMMAND_DELIMITER.length() < this->command_data_.length() && - static_cast(this->command_data_[to_process_length + COMMAND_DELIMITER.length()]) == 0xFF) { + while (to_process_length + DELIMITER_SIZE < this->command_data_.length() && + static_cast(this->command_data_[to_process_length + DELIMITER_SIZE]) == 0xFF) { ++to_process_length; ESP_LOGN(TAG, "Add 0xFF"); } @@ -829,7 +834,7 @@ void Nextion::process_nextion_commands_() { break; } - this->command_data_.erase(0, to_process_length + COMMAND_DELIMITER.length() + 1); + this->command_data_.erase(0, to_process_length + DELIMITER_SIZE + 1); } const uint32_t ms = App.get_loop_component_start_time(); diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index bb5998cf5d..217d2e605d 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -29,8 +29,6 @@ class NextionComponentBase; using nextion_writer_t = display::DisplayWriter; -static const std::string COMMAND_DELIMITER{static_cast(255), static_cast(255), static_cast(255)}; - #ifdef USE_NEXTION_COMMAND_SPACING class NextionCommandPacer { public: From 2d9922496cd94ed43a0a5eec7193ff9f581c48a8 Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Fri, 27 Mar 2026 17:02:45 +0100 Subject: [PATCH 020/160] [git] Add support for subpath to computed destination directory (#15135) --- esphome/git.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/git.py b/esphome/git.py index a45768b5cd..096ff483a7 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -102,6 +102,7 @@ def clone_or_update( username: str = None, password: str = None, submodules: list[str] | None = None, + subpath: Path | None = None, _recover_broken: bool = True, ) -> tuple[Path, Callable[[], None] | None]: key = f"{url}@{ref}" @@ -112,6 +113,9 @@ def clone_or_update( ) repo_dir = _compute_destination_path(key, domain) + if subpath: + repo_dir = repo_dir / subpath + if not repo_dir.is_dir(): _LOGGER.info("Cloning %s", key) _LOGGER.debug("Location: %s", repo_dir) From 73e939ffb5fa41be407812fa069be76f45d968e6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:13:24 -0400 Subject: [PATCH 021/160] [sgp4x] Fix NOx index_offset default (should be 1, not 100) (#15212) --- esphome/components/sgp4x/sensor.py | 39 ++++++++++++++++++------------ 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index ab78ab59d9..8d52ffb4f2 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -44,20 +44,27 @@ def validate_sensors(config): return config -GAS_SENSOR = cv.Schema( - { - cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema( - { - cv.Optional(CONF_INDEX_OFFSET, default=100): cv.int_, - cv.Optional(CONF_LEARNING_TIME_OFFSET_HOURS, default=12): cv.int_, - cv.Optional(CONF_LEARNING_TIME_GAIN_HOURS, default=12): cv.int_, - cv.Optional(CONF_GATING_MAX_DURATION_MINUTES, default=720): cv.int_, - cv.Optional(CONF_STD_INITIAL, default=50): cv.int_, - cv.Optional(CONF_GAIN_FACTOR, default=230): cv.int_, - } - ) - } -) +def _gas_sensor_schema(index_offset_default: int): + return cv.Schema( + { + cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema( + { + cv.Optional( + CONF_INDEX_OFFSET, default=index_offset_default + ): cv.int_, + cv.Optional(CONF_LEARNING_TIME_OFFSET_HOURS, default=12): cv.int_, + cv.Optional(CONF_LEARNING_TIME_GAIN_HOURS, default=12): cv.int_, + cv.Optional(CONF_GATING_MAX_DURATION_MINUTES, default=720): cv.int_, + cv.Optional(CONF_STD_INITIAL, default=50): cv.int_, + cv.Optional(CONF_GAIN_FACTOR, default=230): cv.int_, + } + ) + } + ) + + +VOC_SENSOR = _gas_sensor_schema(100) +NOX_SENSOR = _gas_sensor_schema(1) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -68,13 +75,13 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, - ).extend(GAS_SENSOR), + ).extend(VOC_SENSOR), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, - ).extend(GAS_SENSOR), + ).extend(NOX_SENSOR), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, cv.Optional(CONF_VOC_BASELINE): cv.hex_uint16_t, cv.Optional(CONF_COMPENSATION): cv.Schema( From 1e65165e48274acfd30a8cfd18867608b6dff414 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:19:58 -1000 Subject: [PATCH 022/160] [safe_mode] Migrate SafeModeTrigger to callback automation (#15197) --- esphome/components/safe_mode/__init__.py | 13 ++++--------- esphome/components/safe_mode/automation.h | 10 ---------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index e868985054..da36d21eb7 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_NUM_ATTEMPTS, CONF_REBOOT_TIMEOUT, CONF_SAFE_MODE, - CONF_TRIGGER_ID, KEY_PAST_SAFE_MODE, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -20,7 +19,6 @@ CONF_ON_SAFE_MODE = "on_safe_mode" safe_mode_ns = cg.esphome_ns.namespace("safe_mode") SafeModeComponent = safe_mode_ns.class_("SafeModeComponent", cg.Component) -SafeModeTrigger = safe_mode_ns.class_("SafeModeTrigger", automation.Trigger.template()) MarkSuccessfulAction = safe_mode_ns.class_("MarkSuccessfulAction", automation.Action) @@ -43,11 +41,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_REBOOT_TIMEOUT, default="5min" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_SAFE_MODE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SafeModeTrigger), - } - ), + cv.Optional(CONF_ON_SAFE_MODE): automation.validate_automation({}), } ).extend(cv.COMPONENT_SCHEMA), _remove_id_if_disabled, @@ -80,8 +74,9 @@ async def to_code(config): if on_safe_mode_config := config.get(CONF_ON_SAFE_MODE): cg.add_define("USE_SAFE_MODE_CALLBACK") for conf in on_safe_mode_config: - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_safe_mode_callback", [], conf + ) condition = var.should_enter_safe_mode( config[CONF_NUM_ATTEMPTS], diff --git a/esphome/components/safe_mode/automation.h b/esphome/components/safe_mode/automation.h index dee02c64a0..79b53c0881 100644 --- a/esphome/components/safe_mode/automation.h +++ b/esphome/components/safe_mode/automation.h @@ -1,19 +1,9 @@ #pragma once -#include "esphome/core/defines.h" #include "esphome/core/automation.h" #include "safe_mode.h" namespace esphome::safe_mode { -#ifdef USE_SAFE_MODE_CALLBACK -class SafeModeTrigger final : public Trigger<> { - public: - explicit SafeModeTrigger(SafeModeComponent *parent) { - parent->add_on_safe_mode_callback([this]() { trigger(); }); - } -}; -#endif // USE_SAFE_MODE_CALLBACK - template class MarkSuccessfulAction : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->mark_successful(); } From b0f6a94df51a40c163627aa7e12a1c3947369573 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:20:11 -1000 Subject: [PATCH 023/160] [sml] Migrate DataTrigger to callback automation (#15233) --- esphome/components/sml/__init__.py | 23 +++++------------------ esphome/components/sml/automation.h | 19 ------------------- 2 files changed, 5 insertions(+), 37 deletions(-) delete mode 100644 esphome/components/sml/automation.h diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index eaeddce390..1bf0d97d65 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -4,7 +4,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_DATA, CONF_TRIGGER_ID +from esphome.const import CONF_ID, CONF_ON_DATA CODEOWNERS = ["@alengwenus"] @@ -18,24 +18,11 @@ CONF_SML_ID = "sml_id" CONF_OBIS_CODE = "obis_code" CONF_SERVER_ID = "server_id" -sml_ns = cg.esphome_ns.namespace("sml") - -DataTrigger = sml_ns.class_( - "DataTrigger", - automation.Trigger.template( - cg.std_vector.template(cg.uint8).operator("ref"), cg.bool_ - ), -) - CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(Sml), - cv.Optional(CONF_ON_DATA): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DataTrigger), - } - ), + cv.Optional(CONF_ON_DATA): automation.validate_automation({}), } ).extend(uart.UART_DEVICE_SCHEMA) @@ -45,9 +32,9 @@ async def to_code(config): await cg.register_component(var, config) await uart.register_uart_device(var, config) for conf in config.get(CONF_ON_DATA, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, + await automation.build_callback_automation( + var, + "add_on_data_callback", [ ( cg.std_vector.template(cg.uint8).operator("ref").operator("const"), diff --git a/esphome/components/sml/automation.h b/esphome/components/sml/automation.h deleted file mode 100644 index d51063065d..0000000000 --- a/esphome/components/sml/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "sml.h" - -#include - -namespace esphome { -namespace sml { - -class DataTrigger : public Trigger &, bool> { - public: - explicit DataTrigger(Sml *sml) { - sml->add_on_data_callback([this](const std::vector &data, bool valid) { this->trigger(data, valid); }); - } -}; - -} // namespace sml -} // namespace esphome From b41634e19af272b8c146d3912edf2cba3191b2eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:20:24 -1000 Subject: [PATCH 024/160] [alarm_control_panel] Migrate triggers to callback automation (#15198) --- .../alarm_control_panel/__init__.py | 162 +++++------------- .../alarm_control_panel.cpp | 4 +- .../alarm_control_panel/alarm_control_panel.h | 4 +- .../alarm_control_panel/automation.h | 69 ++------ .../mqtt/mqtt_alarm_control_panel.cpp | 3 +- 5 files changed, 68 insertions(+), 174 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index aefb18d25c..4ee073a15b 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_ID, CONF_MQTT_ID, CONF_ON_STATE, - CONF_TRIGGER_ID, CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -34,39 +33,9 @@ CONF_ON_READY = "on_ready" alarm_control_panel_ns = cg.esphome_ns.namespace("alarm_control_panel") AlarmControlPanel = alarm_control_panel_ns.class_("AlarmControlPanel", cg.EntityBase) -StateTrigger = alarm_control_panel_ns.class_( - "StateTrigger", automation.Trigger.template() -) -TriggeredTrigger = alarm_control_panel_ns.class_( - "TriggeredTrigger", automation.Trigger.template() -) -ClearedTrigger = alarm_control_panel_ns.class_( - "ClearedTrigger", automation.Trigger.template() -) -ArmingTrigger = alarm_control_panel_ns.class_( - "ArmingTrigger", automation.Trigger.template() -) -PendingTrigger = alarm_control_panel_ns.class_( - "PendingTrigger", automation.Trigger.template() -) -ArmedHomeTrigger = alarm_control_panel_ns.class_( - "ArmedHomeTrigger", automation.Trigger.template() -) -ArmedNightTrigger = alarm_control_panel_ns.class_( - "ArmedNightTrigger", automation.Trigger.template() -) -ArmedAwayTrigger = alarm_control_panel_ns.class_( - "ArmedAwayTrigger", automation.Trigger.template() -) -DisarmedTrigger = alarm_control_panel_ns.class_( - "DisarmedTrigger", automation.Trigger.template() -) -ChimeTrigger = alarm_control_panel_ns.class_( - "ChimeTrigger", automation.Trigger.template() -) -ReadyTrigger = alarm_control_panel_ns.class_( - "ReadyTrigger", automation.Trigger.template() -) +StateAnyForwarder = alarm_control_panel_ns.class_("StateAnyForwarder") +StateEnterForwarder = alarm_control_panel_ns.class_("StateEnterForwarder") +AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState") ArmAwayAction = alarm_control_panel_ns.class_("ArmAwayAction", automation.Action) ArmHomeAction = alarm_control_panel_ns.class_("ArmHomeAction", automation.Action) @@ -89,61 +58,17 @@ _ALARM_CONTROL_PANEL_SCHEMA = ( cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id( mqtt.MQTTAlarmControlPanelComponent ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateTrigger), - } - ), - cv.Optional(CONF_ON_TRIGGERED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TriggeredTrigger), - } - ), - cv.Optional(CONF_ON_ARMING): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmingTrigger), - } - ), - cv.Optional(CONF_ON_PENDING): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PendingTrigger), - } - ), - cv.Optional(CONF_ON_ARMED_HOME): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmedHomeTrigger), - } - ), - cv.Optional(CONF_ON_ARMED_NIGHT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmedNightTrigger), - } - ), - cv.Optional(CONF_ON_ARMED_AWAY): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmedAwayTrigger), - } - ), - cv.Optional(CONF_ON_DISARMED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DisarmedTrigger), - } - ), - cv.Optional(CONF_ON_CLEARED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ClearedTrigger), - } - ), - cv.Optional(CONF_ON_CHIME): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ChimeTrigger), - } - ), - cv.Optional(CONF_ON_READY): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ReadyTrigger), - } - ), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + cv.Optional(CONF_ON_TRIGGERED): automation.validate_automation({}), + cv.Optional(CONF_ON_ARMING): automation.validate_automation({}), + cv.Optional(CONF_ON_PENDING): automation.validate_automation({}), + cv.Optional(CONF_ON_ARMED_HOME): automation.validate_automation({}), + cv.Optional(CONF_ON_ARMED_NIGHT): automation.validate_automation({}), + cv.Optional(CONF_ON_ARMED_AWAY): automation.validate_automation({}), + cv.Optional(CONF_ON_DISARMED): automation.validate_automation({}), + cv.Optional(CONF_ON_CLEARED): automation.validate_automation({}), + cv.Optional(CONF_ON_CHIME): automation.validate_automation({}), + cv.Optional(CONF_ON_READY): automation.validate_automation({}), } ) ) @@ -189,38 +114,39 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id( @setup_entity("alarm_control_panel") async def setup_alarm_control_panel_core_(var, config): for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_TRIGGERED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_ARMING, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_PENDING, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_ARMED_HOME, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_ARMED_NIGHT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_ARMED_AWAY, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_DISARMED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [], conf, forwarder=StateAnyForwarder + ) + _STATE_ENTER_MAP = { + CONF_ON_TRIGGERED: AlarmControlPanelState.ACP_STATE_TRIGGERED, + CONF_ON_ARMING: AlarmControlPanelState.ACP_STATE_ARMING, + CONF_ON_PENDING: AlarmControlPanelState.ACP_STATE_PENDING, + CONF_ON_ARMED_HOME: AlarmControlPanelState.ACP_STATE_ARMED_HOME, + CONF_ON_ARMED_NIGHT: AlarmControlPanelState.ACP_STATE_ARMED_NIGHT, + CONF_ON_ARMED_AWAY: AlarmControlPanelState.ACP_STATE_ARMED_AWAY, + CONF_ON_DISARMED: AlarmControlPanelState.ACP_STATE_DISARMED, + } + for conf_key, state_enum in _STATE_ENTER_MAP.items(): + for conf in config.get(conf_key, []): + await automation.build_callback_automation( + var, + "add_on_state_callback", + [], + conf, + forwarder=StateEnterForwarder.template(state_enum), + ) for conf in config.get(CONF_ON_CLEARED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_cleared_callback", [], conf + ) for conf in config.get(CONF_ON_CHIME, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_chime_callback", [], conf + ) for conf in config.get(CONF_ON_READY, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_ready_callback", [], conf + ) if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) if mqtt_id := config.get(CONF_MQTT_ID): diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.cpp b/esphome/components/alarm_control_panel/alarm_control_panel.cpp index 623241851a..fc72c13ce3 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel.cpp @@ -35,8 +35,8 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) { LOG_STR_ARG(alarm_control_panel_state_to_string(state)), LOG_STR_ARG(alarm_control_panel_state_to_string(prev_state))); this->current_state_ = state; - // Single state callback - triggers check get_state() for specific states - this->state_callback_.call(); + // Single state callback - listeners receive the new state as an argument + this->state_callback_.call(state); #if defined(USE_ALARM_CONTROL_PANEL) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_alarm_control_panel_update(this); #endif diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index cf99d359e7..e748b8621b 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -145,8 +145,8 @@ class AlarmControlPanel : public EntityBase { uint32_t last_update_; // the call control function virtual void control(const AlarmControlPanelCall &call) = 0; - // state callback - triggers check get_state() for specific state - LazyCallbackManager state_callback_{}; + // state callback - passes the new state to listeners + LazyCallbackManager state_callback_{}; // clear callback - fires when leaving TRIGGERED state LazyCallbackManager cleared_callback_{}; // chime callback diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index 4ff34de0d5..022d2650d2 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -5,60 +5,27 @@ namespace esphome::alarm_control_panel { -/// Trigger on any state change -class StateTrigger : public Trigger<> { - public: - explicit StateTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_state_callback([this]() { this->trigger(); }); +/// Callback forwarder that triggers an Automation<> on any state change. +/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. +struct StateAnyForwarder { + Automation<> *automation; + void operator()(AlarmControlPanelState /*state*/) const { this->automation->trigger(); } +}; + +/// Callback forwarder that triggers an Automation<> only when the alarm enters a specific state. +/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. +template struct StateEnterForwarder { + Automation<> *automation; + void operator()(AlarmControlPanelState state) const { + if (state == State) + this->automation->trigger(); } }; -/// Template trigger that fires when entering a specific state -template class StateEnterTrigger : public Trigger<> { - public: - explicit StateEnterTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { - alarm_control_panel->add_on_state_callback([this]() { - if (this->alarm_control_panel_->get_state() == State) - this->trigger(); - }); - } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -// Type aliases for state-specific triggers -using TriggeredTrigger = StateEnterTrigger; -using ArmingTrigger = StateEnterTrigger; -using PendingTrigger = StateEnterTrigger; -using ArmedHomeTrigger = StateEnterTrigger; -using ArmedNightTrigger = StateEnterTrigger; -using ArmedAwayTrigger = StateEnterTrigger; -using DisarmedTrigger = StateEnterTrigger; - -/// Trigger when leaving TRIGGERED state (alarm cleared) -class ClearedTrigger : public Trigger<> { - public: - explicit ClearedTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_cleared_callback([this]() { this->trigger(); }); - } -}; - -/// Trigger on chime event (zone opened while disarmed) -class ChimeTrigger : public Trigger<> { - public: - explicit ChimeTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_chime_callback([this]() { this->trigger(); }); - } -}; - -/// Trigger on ready state change -class ReadyTrigger : public Trigger<> { - public: - explicit ReadyTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_ready_callback([this]() { this->trigger(); }); - } -}; +static_assert(sizeof(StateAnyForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v>); template class ArmAwayAction : public Action { public: diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp index 74a60b3624..f059360e23 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp @@ -48,7 +48,8 @@ static bool apply_command(AlarmControlPanelCall &call, const char *state) { } void MQTTAlarmControlPanelComponent::setup() { - this->alarm_control_panel_->add_on_state_callback([this]() { this->publish_state(); }); + this->alarm_control_panel_->add_on_state_callback( + [this](AlarmControlPanelState /*state*/) { this->publish_state(); }); this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &payload) { auto call = this->alarm_control_panel_->make_call(); if (!payload.empty() && payload[0] == '{') { From dea8fdd906a7fc79648d05dfeb74d6aaadad55e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:20:35 -1000 Subject: [PATCH 025/160] [lock] Migrate LockStateTrigger to callback automation (#15199) --- esphome/components/copy/lock/copy_lock.cpp | 2 +- esphome/components/lock/__init__.py | 34 ++++++++++------------ esphome/components/lock/automation.h | 22 ++++++-------- esphome/components/lock/lock.cpp | 2 +- esphome/components/lock/lock.h | 4 +-- esphome/components/mqtt/mqtt_lock.cpp | 3 +- 6 files changed, 30 insertions(+), 37 deletions(-) diff --git a/esphome/components/copy/lock/copy_lock.cpp b/esphome/components/copy/lock/copy_lock.cpp index 25bd8c33ef..c846954510 100644 --- a/esphome/components/copy/lock/copy_lock.cpp +++ b/esphome/components/copy/lock/copy_lock.cpp @@ -7,7 +7,7 @@ namespace copy { static const char *const TAG = "copy.lock"; void CopyLock::setup() { - source_->add_on_state_callback([this]() { this->publish_state(source_->state); }); + source_->add_on_state_callback([this](lock::LockState state) { this->publish_state(state); }); traits.set_assumed_state(source_->traits.get_assumed_state()); traits.set_requires_code(source_->traits.get_requires_code()); diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index fe4db23ae3..0df4b20cba 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_MQTT_ID, CONF_ON_LOCK, CONF_ON_UNLOCK, - CONF_TRIGGER_ID, CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -31,8 +30,7 @@ OpenAction = lock_ns.class_("OpenAction", automation.Action) LockPublishAction = lock_ns.class_("LockPublishAction", automation.Action) LockCondition = lock_ns.class_("LockCondition", Condition) -LockLockTrigger = lock_ns.class_("LockLockTrigger", automation.Trigger.template()) -LockUnlockTrigger = lock_ns.class_("LockUnlockTrigger", automation.Trigger.template()) +LockStateForwarder = lock_ns.class_("LockStateForwarder") LockState = lock_ns.enum("LockState") @@ -52,16 +50,8 @@ _LOCK_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTLockComponent), - cv.Optional(CONF_ON_LOCK): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LockLockTrigger), - } - ), - cv.Optional(CONF_ON_UNLOCK): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LockUnlockTrigger), - } - ), + cv.Optional(CONF_ON_LOCK): automation.validate_automation({}), + cv.Optional(CONF_ON_UNLOCK): automation.validate_automation({}), } ) ) @@ -93,12 +83,18 @@ def lock_schema( @setup_entity("lock") async def _setup_lock_core(var, config): - for conf in config.get(CONF_ON_LOCK, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_UNLOCK, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + for conf_key, state_enum in ( + (CONF_ON_LOCK, LockState.LOCK_STATE_LOCKED), + (CONF_ON_UNLOCK, LockState.LOCK_STATE_UNLOCKED), + ): + for conf in config.get(conf_key, []): + await automation.build_callback_automation( + var, + "add_on_state_callback", + [], + conf, + forwarder=LockStateForwarder.template(state_enum), + ) if mqtt_id := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index 6f3c422693..c140bc568f 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -49,21 +49,17 @@ template class LockCondition : public Condition { bool state_; }; -template class LockStateTrigger : public Trigger<> { - public: - explicit LockStateTrigger(Lock *a_lock) : lock_(a_lock) { - a_lock->add_on_state_callback([this]() { - if (this->lock_->state == State) { - this->trigger(); - } - }); +/// Callback forwarder that triggers an Automation<> only when a specific lock state is entered. +/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. +template struct LockStateForwarder { + Automation<> *automation; + void operator()(LockState state) const { + if (state == State) + this->automation->trigger(); } - - protected: - Lock *lock_; }; -using LockLockTrigger = LockStateTrigger; -using LockUnlockTrigger = LockStateTrigger; +static_assert(sizeof(LockStateForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v>); } // namespace esphome::lock diff --git a/esphome/components/lock/lock.cpp b/esphome/components/lock/lock.cpp index 90937485b9..3ff131af3d 100644 --- a/esphome/components/lock/lock.cpp +++ b/esphome/components/lock/lock.cpp @@ -42,7 +42,7 @@ void Lock::publish_state(LockState state) { this->state = state; this->rtc_.save(&this->state); ESP_LOGV(TAG, "'%s' >> %s", this->name_.c_str(), LOG_STR_ARG(lock_state_to_string(state))); - this->state_callback_.call(); + this->state_callback_.call(state); #if defined(USE_LOCK) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_lock_update(this); #endif diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 707431d543..543a4b51a8 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -148,7 +148,7 @@ class Lock : public EntityBase { /** Set callback for state changes. * - * @param callback The void(bool) callback. + * @param callback The void(LockState) callback. */ template void add_on_state_callback(F &&callback) { this->state_callback_.add(std::forward(callback)); @@ -178,7 +178,7 @@ class Lock : public EntityBase { */ virtual void control(const LockCall &call) = 0; - LazyCallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; Deduplicator publish_dedup_; ESPPreferenceObject rtc_; }; diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 45d8e4698f..7920187f92 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -28,7 +28,8 @@ void MQTTLockComponent::setup() { this->status_momentary_warning("state", 5000); } }); - this->lock_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); }); + this->lock_->add_on_state_callback( + [this](LockState /*state*/) { this->defer("send", [this]() { this->publish_state(); }); }); } void MQTTLockComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Lock '%s': ", this->lock_->get_name().c_str()); From 2e42547d32edce07150f925e7bc8fd0c03f7b814 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:20:46 -1000 Subject: [PATCH 026/160] [media_player] Migrate triggers to callback automation (#15200) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/components/media_player/__init__.py | 42 ++++++++++--------- esphome/components/media_player/automation.h | 41 ++++++++---------- .../components/media_player/media_player.cpp | 2 +- .../components/media_player/media_player.h | 2 +- .../voice_assistant/voice_assistant.cpp | 4 +- 5 files changed, 44 insertions(+), 47 deletions(-) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index a5baca2994..767916ad88 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_ON_STATE, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, - CONF_TRIGGER_ID, CONF_VOLUME, ) from esphome.core import CORE @@ -65,15 +64,19 @@ _COMMAND_ACTIONS = [ "clear_playlist", ] -# State triggers: (config_key, C++ class name) +StateAnyForwarder = media_player_ns.class_("StateAnyForwarder") +StateEnterForwarder = media_player_ns.class_("StateEnterForwarder") +MediaPlayerState = media_player_ns.enum("MediaPlayerState") + +# State triggers: (config_key, state enum or None for any-state) _STATE_TRIGGERS = [ - (CONF_ON_STATE, "StateTrigger"), - (CONF_ON_IDLE, "IdleTrigger"), - (CONF_ON_PLAY, "PlayTrigger"), - (CONF_ON_PAUSE, "PauseTrigger"), - (CONF_ON_ANNOUNCEMENT, "AnnouncementTrigger"), - (CONF_ON_TURN_ON, "OnTrigger"), - (CONF_ON_TURN_OFF, "OffTrigger"), + (CONF_ON_STATE, None), + (CONF_ON_IDLE, MediaPlayerState.MEDIA_PLAYER_STATE_IDLE), + (CONF_ON_PLAY, MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING), + (CONF_ON_PAUSE, MediaPlayerState.MEDIA_PLAYER_STATE_PAUSED), + (CONF_ON_ANNOUNCEMENT, MediaPlayerState.MEDIA_PLAYER_STATE_ANNOUNCING), + (CONF_ON_TURN_ON, MediaPlayerState.MEDIA_PLAYER_STATE_ON), + (CONF_ON_TURN_OFF, MediaPlayerState.MEDIA_PLAYER_STATE_OFF), ] # State conditions that all share the same schema and codegen handler @@ -98,10 +101,15 @@ VolumeSetAction = media_player_ns.class_( @setup_entity("media_player") async def setup_media_player_core_(var, config): - for conf_key, _ in _STATE_TRIGGERS: + for conf_key, state_enum in _STATE_TRIGGERS: for conf in config.get(conf_key, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + if state_enum is None: + forwarder = StateAnyForwarder + else: + forwarder = StateEnterForwarder.template(state_enum) + await automation.build_callback_automation( + var, "add_on_state_callback", [], conf, forwarder=forwarder + ) async def register_media_player(var, config): @@ -120,14 +128,8 @@ async def new_media_player(config, *args): _MEDIA_PLAYER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( { - cv.Optional(conf_key): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - media_player_ns.class_(class_name, automation.Trigger.template()) - ), - } - ) - for conf_key, class_name in _STATE_TRIGGERS + cv.Optional(conf_key): automation.validate_automation({}) + for conf_key, _ in _STATE_TRIGGERS } ) diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 031f6657f4..658381ef90 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -71,32 +71,27 @@ template class VolumeSetAction : public Action, public Pa void play(const Ts &...x) override { this->parent_->make_call().set_volume(this->volume_.value(x...)).perform(); } }; -class StateTrigger : public Trigger<> { - public: - explicit StateTrigger(MediaPlayer *player) { - player->add_on_state_callback([this]() { this->trigger(); }); +/// Callback forwarder that triggers an Automation<> on any state change. +/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. +struct StateAnyForwarder { + Automation<> *automation; + void operator()(MediaPlayerState /*state*/) const { this->automation->trigger(); } +}; + +/// Callback forwarder that triggers an Automation<> only when a specific media player state is entered. +/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. +template struct StateEnterForwarder { + Automation<> *automation; + void operator()(MediaPlayerState state) const { + if (state == State) + this->automation->trigger(); } }; -template class MediaPlayerStateTrigger : public Trigger<> { - public: - explicit MediaPlayerStateTrigger(MediaPlayer *player) : player_(player) { - player->add_on_state_callback([this]() { - if (this->player_->state == State) - this->trigger(); - }); - } - - protected: - MediaPlayer *player_; -}; - -using IdleTrigger = MediaPlayerStateTrigger; -using PlayTrigger = MediaPlayerStateTrigger; -using PauseTrigger = MediaPlayerStateTrigger; -using AnnouncementTrigger = MediaPlayerStateTrigger; -using OnTrigger = MediaPlayerStateTrigger; -using OffTrigger = MediaPlayerStateTrigger; +static_assert(sizeof(StateAnyForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v>); template class IsIdleCondition : public Condition, public Parented { public: diff --git a/esphome/components/media_player/media_player.cpp b/esphome/components/media_player/media_player.cpp index a0eb7b5500..48d23fa0b1 100644 --- a/esphome/components/media_player/media_player.cpp +++ b/esphome/components/media_player/media_player.cpp @@ -199,7 +199,7 @@ MediaPlayerCall &MediaPlayerCall::set_announcement(bool announce) { } void MediaPlayer::publish_state() { - this->state_callback_.call(); + this->state_callback_.call(this->state); #if defined(USE_MEDIA_PLAYER) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_media_player_update(this); #endif diff --git a/esphome/components/media_player/media_player.h b/esphome/components/media_player/media_player.h index 26eca469e7..d5d0020797 100644 --- a/esphome/components/media_player/media_player.h +++ b/esphome/components/media_player/media_player.h @@ -168,7 +168,7 @@ class MediaPlayer : public EntityBase { virtual void control(const MediaPlayerCall &call) = 0; - LazyCallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; }; } // namespace media_player diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 15124e422f..ddce606b2c 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -39,8 +39,8 @@ void VoiceAssistant::setup() { #ifdef USE_MEDIA_PLAYER if (this->media_player_ != nullptr) { - this->media_player_->add_on_state_callback([this]() { - switch (this->media_player_->state) { + this->media_player_->add_on_state_callback([this](media_player::MediaPlayerState state) { + switch (state) { case media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING: if (this->media_player_response_state_ == MediaPlayerResponseState::URL_SENT) { // State changed to announcing after receiving the url From a2d452684a0cc6620e0a3e1bce746b0ef6a80ff3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:21:03 -1000 Subject: [PATCH 027/160] [ld2450] Migrate LD2450DataTrigger to callback automation (#15201) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/components/ld2450/__init__.py | 14 +++++--------- esphome/components/ld2450/ld2450.h | 8 -------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 5854a5794c..37bf12bafc 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE, CONF_TRIGGER_ID +from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] @@ -12,7 +12,6 @@ MULTI_CONF = True ld2450_ns = cg.esphome_ns.namespace("ld2450") LD2450Component = ld2450_ns.class_("LD2450Component", cg.Component, uart.UARTDevice) -LD2450DataTrigger = ld2450_ns.class_("LD2450DataTrigger", automation.Trigger.template()) CONF_LD2450_ID = "ld2450_id" @@ -23,11 +22,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_THROTTLE): cv.invalid( f"{CONF_THROTTLE} has been removed; use per-sensor filters, instead" ), - cv.Optional(CONF_ON_DATA): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LD2450DataTrigger), - } - ), + cv.Optional(CONF_ON_DATA): automation.validate_automation({}), } ) .extend(uart.UART_DEVICE_SCHEMA) @@ -54,5 +49,6 @@ async def to_code(config): await cg.register_component(var, config) await uart.register_uart_device(var, config) for conf in config.get(CONF_ON_DATA, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_data_callback", [], conf + ) diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index e774dd9c75..cbcdec10b3 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/defines.h" #include "esphome/core/component.h" #ifdef USE_SENSOR @@ -201,11 +200,4 @@ class LD2450Component : public Component, public uart::UARTDevice { LazyCallbackManager data_callback_; }; -class LD2450DataTrigger : public Trigger<> { - public: - explicit LD2450DataTrigger(LD2450Component *parent) { - parent->add_on_data_callback([this]() { this->trigger(); }); - } -}; - } // namespace esphome::ld2450 From 83b3187126be87ac2d7a97db9dd340d8679180e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:21:16 -1000 Subject: [PATCH 028/160] [rtttl] Migrate FinishedPlaybackTrigger to callback automation (#15202) --- esphome/components/rtttl/__init__.py | 25 +++++-------------------- esphome/components/rtttl/rtttl.h | 7 ------- 2 files changed, 5 insertions(+), 27 deletions(-) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 3566734200..638e950ba6 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -5,14 +5,7 @@ import esphome.codegen as cg from esphome.components.output import FloatOutput from esphome.components.speaker import Speaker import esphome.config_validation as cv -from esphome.const import ( - CONF_GAIN, - CONF_ID, - CONF_OUTPUT, - CONF_PLATFORM, - CONF_SPEAKER, - CONF_TRIGGER_ID, -) +from esphome.const import CONF_GAIN, CONF_ID, CONF_OUTPUT, CONF_PLATFORM, CONF_SPEAKER import esphome.final_validate as fv _LOGGER = logging.getLogger(__name__) @@ -26,9 +19,6 @@ rtttl_ns = cg.esphome_ns.namespace("rtttl") Rtttl = rtttl_ns.class_("Rtttl", cg.Component) PlayAction = rtttl_ns.class_("PlayAction", automation.Action) StopAction = rtttl_ns.class_("StopAction", automation.Action) -FinishedPlaybackTrigger = rtttl_ns.class_( - "FinishedPlaybackTrigger", automation.Trigger.template() -) IsPlayingCondition = rtttl_ns.class_("IsPlayingCondition", automation.Condition) MULTI_CONF = True @@ -40,13 +30,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_OUTPUT): cv.use_id(FloatOutput), cv.Optional(CONF_SPEAKER): cv.use_id(Speaker), cv.Optional(CONF_GAIN, default="0.6"): cv.percentage, - cv.Optional(CONF_ON_FINISHED_PLAYBACK): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - FinishedPlaybackTrigger - ), - } - ), + cv.Optional(CONF_ON_FINISHED_PLAYBACK): automation.validate_automation({}), } ).extend(cv.COMPONENT_SCHEMA), cv.has_exactly_one_key(CONF_OUTPUT, CONF_SPEAKER), @@ -103,8 +87,9 @@ async def to_code(config): cg.add(var.set_gain(config[CONF_GAIN])) for conf in config.get(CONF_ON_FINISHED_PLAYBACK, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_finished_playback_callback", [], conf + ) @automation.register_action( diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index bff43d2edd..98ed9ba1bf 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -131,11 +131,4 @@ template class IsPlayingCondition : public Condition, pub bool check(const Ts &...x) override { return this->parent_->is_playing(); } }; -class FinishedPlaybackTrigger : public Trigger<> { - public: - explicit FinishedPlaybackTrigger(Rtttl *parent) { - parent->add_on_finished_playback_callback([this]() { this->trigger(); }); - } -}; - } // namespace esphome::rtttl From 4493d2efb6582f020b6ed73879ab56566c088779 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:21:27 -1000 Subject: [PATCH 029/160] [online_image] Migrate triggers to callback automation (#15216) --- esphome/components/online_image/__init__.py | 41 ++++--------------- .../components/online_image/online_image.h | 14 ------- 2 files changed, 9 insertions(+), 46 deletions(-) diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index 292e2bb3bb..5b8294c70e 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -7,14 +7,7 @@ from esphome.components.const import CONF_REQUEST_HEADERS from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent from esphome.components.image import CONF_TRANSPARENCY, add_metadata import esphome.config_validation as cv -from esphome.const import ( - CONF_BUFFER_SIZE, - CONF_ID, - CONF_ON_ERROR, - CONF_TRIGGER_ID, - CONF_TYPE, - CONF_URL, -) +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL from esphome.core import Lambda AUTO_LOAD = ["image", "runtime_image"] @@ -41,14 +34,6 @@ ReleaseImageAction = online_image_ns.class_( "OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage) ) -# Triggers -DownloadFinishedTrigger = online_image_ns.class_( - "DownloadFinishedTrigger", automation.Trigger.template() -) -DownloadErrorTrigger = online_image_ns.class_( - "DownloadErrorTrigger", automation.Trigger.template() -) - ONLINE_IMAGE_SCHEMA = ( runtime_image.runtime_image_schema(OnlineImage) @@ -61,18 +46,8 @@ ONLINE_IMAGE_SCHEMA = ( cv.Optional(CONF_REQUEST_HEADERS): cv.All( cv.Schema({cv.string: cv.templatable(cv.string)}) ), - cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DownloadFinishedTrigger - ), - } - ), - cv.Optional(CONF_ON_ERROR): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DownloadErrorTrigger), - } - ), + cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}), + cv.Optional(CONF_ON_ERROR): automation.validate_automation({}), } ) .extend(cv.polling_component_schema("never")) @@ -165,9 +140,11 @@ async def to_code(config): cg.add(var.add_request_header(key, value)) for conf in config.get(CONF_ON_DOWNLOAD_FINISHED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(bool, "cached")], conf) + await automation.build_callback_automation( + var, "add_on_finished_callback", [(bool, "cached")], conf + ) for conf in config.get(CONF_ON_ERROR, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_error_callback", [], conf + ) diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index 3a348cbb07..816d6525ea 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -129,18 +129,4 @@ template class OnlineImageReleaseAction : public Action { OnlineImage *parent_; }; -class DownloadFinishedTrigger : public Trigger { - public: - explicit DownloadFinishedTrigger(OnlineImage *parent) { - parent->add_on_finished_callback([this](bool cached) { this->trigger(cached); }); - } -}; - -class DownloadErrorTrigger : public Trigger<> { - public: - explicit DownloadErrorTrigger(OnlineImage *parent) { - parent->add_on_error_callback([this]() { this->trigger(); }); - } -}; - } // namespace esphome::online_image From 54283a2599cf5f6958bbb329745afd0dbb800f2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:21:41 -1000 Subject: [PATCH 030/160] [rotary_encoder] Migrate triggers to callback automation (#15217) --- .../rotary_encoder/rotary_encoder.h | 14 -------- esphome/components/rotary_encoder/sensor.py | 34 +++++-------------- 2 files changed, 8 insertions(+), 40 deletions(-) diff --git a/esphome/components/rotary_encoder/rotary_encoder.h b/esphome/components/rotary_encoder/rotary_encoder.h index 4b776fe55e..6f4a4fd83c 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.h +++ b/esphome/components/rotary_encoder/rotary_encoder.h @@ -118,19 +118,5 @@ template class RotaryEncoderSetValueAction : public Action { - public: - explicit RotaryEncoderClockwiseTrigger(RotaryEncoderSensor *parent) { - parent->add_on_clockwise_callback([this]() { this->trigger(); }); - } -}; - -class RotaryEncoderAnticlockwiseTrigger : public Trigger<> { - public: - explicit RotaryEncoderAnticlockwiseTrigger(RotaryEncoderSensor *parent) { - parent->add_on_anticlockwise_callback([this]() { this->trigger(); }); - } -}; - } // namespace rotary_encoder } // namespace esphome diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index be315db55d..e64e44f7c1 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_PIN_B, CONF_RESOLUTION, CONF_RESTORE_MODE, - CONF_TRIGGER_ID, CONF_VALUE, ICON_ROTATE_RIGHT, UNIT_STEPS, @@ -43,13 +42,6 @@ RotaryEncoderSetValueAction = rotary_encoder_ns.class_( "RotaryEncoderSetValueAction", automation.Action ) -RotaryEncoderClockwiseTrigger = rotary_encoder_ns.class_( - "RotaryEncoderClockwiseTrigger", automation.Trigger -) -RotaryEncoderAnticlockwiseTrigger = rotary_encoder_ns.class_( - "RotaryEncoderAnticlockwiseTrigger", automation.Trigger -) - def validate_min_max_value(config): if CONF_MIN_VALUE in config and CONF_MAX_VALUE in config: @@ -81,20 +73,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_RESTORE_MODE, default="RESTORE_DEFAULT_ZERO"): cv.enum( RESTORE_MODES, upper=True, space="_" ), - cv.Optional(CONF_ON_CLOCKWISE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - RotaryEncoderClockwiseTrigger - ), - } - ), - cv.Optional(CONF_ON_ANTICLOCKWISE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - RotaryEncoderAnticlockwiseTrigger - ), - } - ), + cv.Optional(CONF_ON_CLOCKWISE): automation.validate_automation({}), + cv.Optional(CONF_ON_ANTICLOCKWISE): automation.validate_automation({}), } ) .extend(cv.COMPONENT_SCHEMA), @@ -123,11 +103,13 @@ async def to_code(config): cg.add(var.set_max_value(config[CONF_MAX_VALUE])) for conf in config.get(CONF_ON_CLOCKWISE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_clockwise_callback", [], conf + ) for conf in config.get(CONF_ON_ANTICLOCKWISE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_anticlockwise_callback", [], conf + ) @automation.register_action( From 514df6c99af94915523d26e45ad489d4a5ff60d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:21:52 -1000 Subject: [PATCH 031/160] [dfplayer] Migrate FinishedPlaybackTrigger to callback automation (#15218) --- esphome/components/dfplayer/__init__.py | 18 +++++------------- esphome/components/dfplayer/dfplayer.h | 7 ------- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 9df108c9c0..c49420f060 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -2,16 +2,13 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_TRIGGER_ID, CONF_VOLUME +from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_VOLUME DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] dfplayer_ns = cg.esphome_ns.namespace("dfplayer") DFPlayer = dfplayer_ns.class_("DFPlayer", cg.Component) -DFPlayerFinishedPlaybackTrigger = dfplayer_ns.class_( - "DFPlayerFinishedPlaybackTrigger", automation.Trigger.template() -) DFPlayerIsPlayingCondition = dfplayer_ns.class_( "DFPlayerIsPlayingCondition", automation.Condition ) @@ -58,13 +55,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(DFPlayer), - cv.Optional(CONF_ON_FINISHED_PLAYBACK): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DFPlayerFinishedPlaybackTrigger - ), - } - ), + cv.Optional(CONF_ON_FINISHED_PLAYBACK): automation.validate_automation({}), } ).extend(uart.UART_DEVICE_SCHEMA) ) @@ -79,8 +70,9 @@ async def to_code(config): await uart.register_uart_device(var, config) for conf in config.get(CONF_ON_FINISHED_PLAYBACK, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_finished_playback_callback", [], conf + ) @automation.register_action( diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index 2c4ee03470..0d240566c3 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -171,12 +171,5 @@ template class DFPlayerIsPlayingCondition : public Conditionparent_->is_playing(); } }; -class DFPlayerFinishedPlaybackTrigger : public Trigger<> { - public: - explicit DFPlayerFinishedPlaybackTrigger(DFPlayer *parent) { - parent->add_on_finished_playback_callback([this]() { this->trigger(); }); - } -}; - } // namespace dfplayer } // namespace esphome From 623408bbfe2bff8c41964b73afa2a23fbd208e10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:22:02 -1000 Subject: [PATCH 032/160] [hlk_fm22x] Migrate triggers to callback automation (#15219) --- esphome/components/hlk_fm22x/__init__.py | 109 ++++++----------------- esphome/components/hlk_fm22x/hlk_fm22x.h | 46 ---------- 2 files changed, 28 insertions(+), 127 deletions(-) diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index cb6d5cdfd6..c0349319d1 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_NAME, CONF_ON_ENROLLMENT_DONE, CONF_ON_ENROLLMENT_FAILED, - CONF_TRIGGER_ID, ) CODEOWNERS = ["@OnFreund"] @@ -28,33 +27,6 @@ HlkFm22xComponent = hlk_fm22x_ns.class_( "HlkFm22xComponent", cg.PollingComponent, uart.UARTDevice ) -FaceScanMatchedTrigger = hlk_fm22x_ns.class_( - "FaceScanMatchedTrigger", automation.Trigger.template(cg.int16, cg.std_string) -) - -FaceScanUnmatchedTrigger = hlk_fm22x_ns.class_( - "FaceScanUnmatchedTrigger", automation.Trigger.template() -) - -FaceScanInvalidTrigger = hlk_fm22x_ns.class_( - "FaceScanInvalidTrigger", automation.Trigger.template(cg.uint8) -) - -FaceInfoTrigger = hlk_fm22x_ns.class_( - "FaceInfoTrigger", - automation.Trigger.template( - cg.int16, cg.int16, cg.int16, cg.int16, cg.int16, cg.int16, cg.int16, cg.int16 - ), -) - -EnrollmentDoneTrigger = hlk_fm22x_ns.class_( - "EnrollmentDoneTrigger", automation.Trigger.template(cg.int16, cg.uint8) -) - -EnrollmentFailedTrigger = hlk_fm22x_ns.class_( - "EnrollmentFailedTrigger", automation.Trigger.template(cg.uint8) -) - EnrollmentAction = hlk_fm22x_ns.class_("EnrollmentAction", automation.Action) DeleteAction = hlk_fm22x_ns.class_("DeleteAction", automation.Action) DeleteAllAction = hlk_fm22x_ns.class_("DeleteAllAction", automation.Action) @@ -65,46 +37,14 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(HlkFm22xComponent), - cv.Optional(CONF_ON_FACE_SCAN_MATCHED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - FaceScanMatchedTrigger - ), - } - ), + cv.Optional(CONF_ON_FACE_SCAN_MATCHED): automation.validate_automation({}), cv.Optional(CONF_ON_FACE_SCAN_UNMATCHED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - FaceScanUnmatchedTrigger - ), - } - ), - cv.Optional(CONF_ON_FACE_SCAN_INVALID): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - FaceScanInvalidTrigger - ), - } - ), - cv.Optional(CONF_ON_FACE_INFO): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FaceInfoTrigger), - } - ), - cv.Optional(CONF_ON_ENROLLMENT_DONE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - EnrollmentDoneTrigger - ), - } - ), - cv.Optional(CONF_ON_ENROLLMENT_FAILED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - EnrollmentFailedTrigger - ), - } + {} ), + cv.Optional(CONF_ON_FACE_SCAN_INVALID): automation.validate_automation({}), + cv.Optional(CONF_ON_FACE_INFO): automation.validate_automation({}), + cv.Optional(CONF_ON_ENROLLMENT_DONE): automation.validate_automation({}), + cv.Optional(CONF_ON_ENROLLMENT_FAILED): automation.validate_automation({}), } ) .extend(cv.polling_component_schema("50ms")) @@ -118,23 +58,27 @@ async def to_code(config): await uart.register_uart_device(var, config) for conf in config.get(CONF_ON_FACE_SCAN_MATCHED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.int16, "face_id"), (cg.std_string, "name")], conf + await automation.build_callback_automation( + var, + "add_on_face_scan_matched_callback", + [(cg.int16, "face_id"), (cg.std_string, "name")], + conf, ) for conf in config.get(CONF_ON_FACE_SCAN_UNMATCHED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_face_scan_unmatched_callback", [], conf + ) for conf in config.get(CONF_ON_FACE_SCAN_INVALID, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.uint8, "error")], conf) + await automation.build_callback_automation( + var, "add_on_face_scan_invalid_callback", [(cg.uint8, "error")], conf + ) for conf in config.get(CONF_ON_FACE_INFO, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, + await automation.build_callback_automation( + var, + "add_on_face_info_callback", [ (cg.int16, "status"), (cg.int16, "left"), @@ -149,14 +93,17 @@ async def to_code(config): ) for conf in config.get(CONF_ON_ENROLLMENT_DONE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.int16, "face_id"), (cg.uint8, "direction")], conf + await automation.build_callback_automation( + var, + "add_on_enrollment_done_callback", + [(cg.int16, "face_id"), (cg.uint8, "direction")], + conf, ) for conf in config.get(CONF_ON_ENROLLMENT_FAILED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.uint8, "error")], conf) + await automation.build_callback_automation( + var, "add_on_enrollment_failed_callback", [(cg.uint8, "error")], conf + ) @automation.register_action( diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index d897d51881..fd8257b435 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -141,52 +141,6 @@ class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice { CallbackManager enrollment_failed_callback_; }; -class FaceScanMatchedTrigger : public Trigger { - public: - explicit FaceScanMatchedTrigger(HlkFm22xComponent *parent) { - parent->add_on_face_scan_matched_callback( - [this](int16_t face_id, const std::string &name) { this->trigger(face_id, name); }); - } -}; - -class FaceScanUnmatchedTrigger : public Trigger<> { - public: - explicit FaceScanUnmatchedTrigger(HlkFm22xComponent *parent) { - parent->add_on_face_scan_unmatched_callback([this]() { this->trigger(); }); - } -}; - -class FaceScanInvalidTrigger : public Trigger { - public: - explicit FaceScanInvalidTrigger(HlkFm22xComponent *parent) { - parent->add_on_face_scan_invalid_callback([this](uint8_t error) { this->trigger(error); }); - } -}; - -class FaceInfoTrigger : public Trigger { - public: - explicit FaceInfoTrigger(HlkFm22xComponent *parent) { - parent->add_on_face_info_callback( - [this](int16_t status, int16_t left, int16_t top, int16_t right, int16_t bottom, int16_t yaw, int16_t pitch, - int16_t roll) { this->trigger(status, left, top, right, bottom, yaw, pitch, roll); }); - } -}; - -class EnrollmentDoneTrigger : public Trigger { - public: - explicit EnrollmentDoneTrigger(HlkFm22xComponent *parent) { - parent->add_on_enrollment_done_callback( - [this](int16_t face_id, uint8_t direction) { this->trigger(face_id, direction); }); - } -}; - -class EnrollmentFailedTrigger : public Trigger { - public: - explicit EnrollmentFailedTrigger(HlkFm22xComponent *parent) { - parent->add_on_enrollment_failed_callback([this](uint8_t error) { this->trigger(error); }); - } -}; - template class EnrollmentAction : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, name) From a4a8fa3027088c2a9c3ef8cc96c004cfabfe36b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:22:14 -1000 Subject: [PATCH 033/160] [pn532] Migrate PN532OnFinishedWriteTrigger to callback automation (#15220) --- esphome/components/pn532/__init__.py | 17 ++++------------- esphome/components/pn532/pn532.h | 7 ------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index 6f679ed10a..4ccda49a72 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -19,10 +19,6 @@ CONF_PN532_ID = "pn532_id" pn532_ns = cg.esphome_ns.namespace("pn532") PN532 = pn532_ns.class_("PN532", cg.PollingComponent) -PN532OnFinishedWriteTrigger = pn532_ns.class_( - "PN532OnFinishedWriteTrigger", automation.Trigger.template() -) - PN532IsWritingCondition = pn532_ns.class_( "PN532IsWritingCondition", automation.Condition ) @@ -35,13 +31,7 @@ PN532_SCHEMA = cv.Schema( cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), } ), - cv.Optional(CONF_ON_FINISHED_WRITE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - PN532OnFinishedWriteTrigger - ), - } - ), + cv.Optional(CONF_ON_FINISHED_WRITE): automation.validate_automation({}), cv.Optional(CONF_ON_TAG_REMOVED): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), @@ -77,8 +67,9 @@ async def setup_pn532(var, config): ) for conf in config.get(CONF_ON_FINISHED_WRITE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_finished_write_callback", [], conf + ) @automation.register_condition( diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index 1f6a6b3bc3..b76cbb1946 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -133,13 +133,6 @@ class PN532BinarySensor : public binary_sensor::BinarySensor { bool found_{false}; }; -class PN532OnFinishedWriteTrigger : public Trigger<> { - public: - explicit PN532OnFinishedWriteTrigger(PN532 *parent) { - parent->add_on_finished_write_callback([this]() { this->trigger(); }); - } -}; - template class PN532IsWritingCondition : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } From 985477f2cfa40cc31a97f3169364b73db4d34a91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:22:25 -1000 Subject: [PATCH 034/160] [pn7150][pn7160] Migrate triggers to callback automation (#15221) --- esphome/components/pn7150/__init__.py | 34 ++++++-------------------- esphome/components/pn7150/automation.h | 14 ----------- esphome/components/pn7160/__init__.py | 34 ++++++-------------------- esphome/components/pn7160/automation.h | 14 ----------- 4 files changed, 16 insertions(+), 80 deletions(-) diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index 6af1412881..c8723dc31c 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -50,14 +50,6 @@ SetWriteMessageAction = pn7150_ns.class_("SetWriteMessageAction", automation.Act SetWriteModeAction = pn7150_ns.class_("SetWriteModeAction", automation.Action) -PN7150OnEmulatedTagScanTrigger = pn7150_ns.class_( - "PN7150OnEmulatedTagScanTrigger", automation.Trigger.template() -) - -PN7150OnFinishedWriteTrigger = pn7150_ns.class_( - "PN7150OnFinishedWriteTrigger", automation.Trigger.template() -) - PN7150IsWritingCondition = pn7150_ns.class_( "PN7150IsWritingCondition", automation.Condition ) @@ -83,20 +75,8 @@ SET_MESSAGE_ACTION_SCHEMA = cv.Schema( PN7150_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(PN7150), - cv.Optional(CONF_ON_EMULATED_TAG_SCAN): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - PN7150OnEmulatedTagScanTrigger - ), - } - ), - cv.Optional(CONF_ON_FINISHED_WRITE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - PN7150OnFinishedWriteTrigger - ), - } - ), + cv.Optional(CONF_ON_EMULATED_TAG_SCAN): automation.validate_automation({}), + cv.Optional(CONF_ON_FINISHED_WRITE): automation.validate_automation({}), cv.Optional(CONF_ON_TAG): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), @@ -215,12 +195,14 @@ async def setup_pn7150(var, config): ) for conf in config.get(CONF_ON_EMULATED_TAG_SCAN, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_emulated_tag_scan_callback", [], conf + ) for conf in config.get(CONF_ON_FINISHED_WRITE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_finished_write_callback", [], conf + ) @automation.register_condition( diff --git a/esphome/components/pn7150/automation.h b/esphome/components/pn7150/automation.h index 21329a998a..a8c65ae633 100644 --- a/esphome/components/pn7150/automation.h +++ b/esphome/components/pn7150/automation.h @@ -7,20 +7,6 @@ namespace esphome { namespace pn7150 { -class PN7150OnEmulatedTagScanTrigger : public Trigger<> { - public: - explicit PN7150OnEmulatedTagScanTrigger(PN7150 *parent) { - parent->add_on_emulated_tag_scan_callback([this]() { this->trigger(); }); - } -}; - -class PN7150OnFinishedWriteTrigger : public Trigger<> { - public: - explicit PN7150OnFinishedWriteTrigger(PN7150 *parent) { - parent->add_on_finished_write_callback([this]() { this->trigger(); }); - } -}; - template class PN7150IsWritingCondition : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index 54e4b74796..e382594b93 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -52,14 +52,6 @@ SetWriteMessageAction = pn7160_ns.class_("SetWriteMessageAction", automation.Act SetWriteModeAction = pn7160_ns.class_("SetWriteModeAction", automation.Action) -PN7160OnEmulatedTagScanTrigger = pn7160_ns.class_( - "PN7160OnEmulatedTagScanTrigger", automation.Trigger.template() -) - -PN7160OnFinishedWriteTrigger = pn7160_ns.class_( - "PN7160OnFinishedWriteTrigger", automation.Trigger.template() -) - PN7160IsWritingCondition = pn7160_ns.class_( "PN7160IsWritingCondition", automation.Condition ) @@ -85,20 +77,8 @@ SET_MESSAGE_ACTION_SCHEMA = cv.Schema( PN7160_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(PN7160), - cv.Optional(CONF_ON_EMULATED_TAG_SCAN): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - PN7160OnEmulatedTagScanTrigger - ), - } - ), - cv.Optional(CONF_ON_FINISHED_WRITE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - PN7160OnFinishedWriteTrigger - ), - } - ), + cv.Optional(CONF_ON_EMULATED_TAG_SCAN): automation.validate_automation({}), + cv.Optional(CONF_ON_FINISHED_WRITE): automation.validate_automation({}), cv.Optional(CONF_ON_TAG): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), @@ -227,12 +207,14 @@ async def setup_pn7160(var, config): ) for conf in config.get(CONF_ON_EMULATED_TAG_SCAN, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_emulated_tag_scan_callback", [], conf + ) for conf in config.get(CONF_ON_FINISHED_WRITE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_finished_write_callback", [], conf + ) @automation.register_condition( diff --git a/esphome/components/pn7160/automation.h b/esphome/components/pn7160/automation.h index 08148c2311..7759da8f53 100644 --- a/esphome/components/pn7160/automation.h +++ b/esphome/components/pn7160/automation.h @@ -7,20 +7,6 @@ namespace esphome { namespace pn7160 { -class PN7160OnEmulatedTagScanTrigger : public Trigger<> { - public: - explicit PN7160OnEmulatedTagScanTrigger(PN7160 *parent) { - parent->add_on_emulated_tag_scan_callback([this]() { this->trigger(); }); - } -}; - -class PN7160OnFinishedWriteTrigger : public Trigger<> { - public: - explicit PN7160OnFinishedWriteTrigger(PN7160 *parent) { - parent->add_on_finished_write_callback([this]() { this->trigger(); }); - } -}; - template class PN7160IsWritingCondition : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } From a5416df6155172ff80869caa8e183cd58e552e18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:22:36 -1000 Subject: [PATCH 035/160] [sim800l] Migrate triggers to callback automation (#15222) --- esphome/components/sim800l/__init__.py | 93 +++++++------------------- esphome/components/sim800l/sim800l.h | 35 ---------- 2 files changed, 23 insertions(+), 105 deletions(-) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index ebb74302a9..91771047e1 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MESSAGE, CONF_TRIGGER_ID +from esphome.const import CONF_ID, CONF_MESSAGE DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] @@ -11,28 +11,6 @@ MULTI_CONF = True sim800l_ns = cg.esphome_ns.namespace("sim800l") Sim800LComponent = sim800l_ns.class_("Sim800LComponent", cg.Component) -Sim800LReceivedMessageTrigger = sim800l_ns.class_( - "Sim800LReceivedMessageTrigger", - automation.Trigger.template(cg.std_string, cg.std_string), -) -Sim800LIncomingCallTrigger = sim800l_ns.class_( - "Sim800LIncomingCallTrigger", - automation.Trigger.template(cg.std_string), -) -Sim800LCallConnectedTrigger = sim800l_ns.class_( - "Sim800LCallConnectedTrigger", - automation.Trigger.template(), -) -Sim800LCallDisconnectedTrigger = sim800l_ns.class_( - "Sim800LCallDisconnectedTrigger", - automation.Trigger.template(), -) - -Sim800LReceivedUssdTrigger = sim800l_ns.class_( - "Sim800LReceivedUssdTrigger", - automation.Trigger.template(cg.std_string), -) - # Actions Sim800LSendSmsAction = sim800l_ns.class_("Sim800LSendSmsAction", automation.Action) Sim800LSendUssdAction = sim800l_ns.class_("Sim800LSendUssdAction", automation.Action) @@ -55,41 +33,11 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(Sim800LComponent), - cv.Optional(CONF_ON_SMS_RECEIVED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Sim800LReceivedMessageTrigger - ), - } - ), - cv.Optional(CONF_ON_INCOMING_CALL): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Sim800LIncomingCallTrigger - ), - } - ), - cv.Optional(CONF_ON_CALL_CONNECTED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Sim800LCallConnectedTrigger - ), - } - ), - cv.Optional(CONF_ON_CALL_DISCONNECTED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Sim800LCallDisconnectedTrigger - ), - } - ), - cv.Optional(CONF_ON_USSD_RECEIVED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Sim800LReceivedUssdTrigger - ), - } - ), + cv.Optional(CONF_ON_SMS_RECEIVED): automation.validate_automation({}), + cv.Optional(CONF_ON_INCOMING_CALL): automation.validate_automation({}), + cv.Optional(CONF_ON_CALL_CONNECTED): automation.validate_automation({}), + cv.Optional(CONF_ON_CALL_DISCONNECTED): automation.validate_automation({}), + cv.Optional(CONF_ON_USSD_RECEIVED): automation.validate_automation({}), } ) .extend(cv.polling_component_schema("5s")) @@ -106,23 +54,28 @@ async def to_code(config): await uart.register_uart_device(var, config) for conf in config.get(CONF_ON_SMS_RECEIVED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.std_string, "message"), (cg.std_string, "sender")], conf + await automation.build_callback_automation( + var, + "add_on_sms_received_callback", + [(cg.std_string, "message"), (cg.std_string, "sender")], + conf, ) for conf in config.get(CONF_ON_INCOMING_CALL, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "caller_id")], conf) + await automation.build_callback_automation( + var, "add_on_incoming_call_callback", [(cg.std_string, "caller_id")], conf + ) for conf in config.get(CONF_ON_CALL_CONNECTED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_call_connected_callback", [], conf + ) for conf in config.get(CONF_ON_CALL_DISCONNECTED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - + await automation.build_callback_automation( + var, "add_on_call_disconnected_callback", [], conf + ) for conf in config.get(CONF_ON_USSD_RECEIVED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "ussd")], conf) + await automation.build_callback_automation( + var, "add_on_ussd_received_callback", [(cg.std_string, "ussd")], conf + ) SIM800L_SEND_SMS_SCHEMA = cv.Schema( diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index d79279ea72..d0da123039 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -121,41 +121,6 @@ class Sim800LComponent : public uart::UARTDevice, public PollingComponent { CallbackManager ussd_received_callback_; }; -class Sim800LReceivedMessageTrigger : public Trigger { - public: - explicit Sim800LReceivedMessageTrigger(Sim800LComponent *parent) { - parent->add_on_sms_received_callback( - [this](const std::string &message, const std::string &sender) { this->trigger(message, sender); }); - } -}; - -class Sim800LIncomingCallTrigger : public Trigger { - public: - explicit Sim800LIncomingCallTrigger(Sim800LComponent *parent) { - parent->add_on_incoming_call_callback([this](const std::string &caller_id) { this->trigger(caller_id); }); - } -}; - -class Sim800LCallConnectedTrigger : public Trigger<> { - public: - explicit Sim800LCallConnectedTrigger(Sim800LComponent *parent) { - parent->add_on_call_connected_callback([this]() { this->trigger(); }); - } -}; - -class Sim800LCallDisconnectedTrigger : public Trigger<> { - public: - explicit Sim800LCallDisconnectedTrigger(Sim800LComponent *parent) { - parent->add_on_call_disconnected_callback([this]() { this->trigger(); }); - } -}; -class Sim800LReceivedUssdTrigger : public Trigger { - public: - explicit Sim800LReceivedUssdTrigger(Sim800LComponent *parent) { - parent->add_on_ussd_received_callback([this](const std::string &ussd) { this->trigger(ussd); }); - } -}; - template class Sim800LSendSmsAction : public Action { public: Sim800LSendSmsAction(Sim800LComponent *parent) : parent_(parent) {} From 6ffb5af60ced80ff47720fc572c4476475954f97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:22:47 -1000 Subject: [PATCH 036/160] [fingerprint_grow] Migrate triggers to callback automation (#15223) --- .../components/fingerprint_grow/__init__.py | 142 +++++------------- .../fingerprint_grow/fingerprint_grow.h | 58 ------- 2 files changed, 36 insertions(+), 164 deletions(-) diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 2637097be8..0b01ba7cab 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -21,7 +21,6 @@ from esphome.const import ( CONF_SENSING_PIN, CONF_SPEED, CONF_STATE, - CONF_TRIGGER_ID, ) CODEOWNERS = ["@OnFreund", "@loongyh", "@alexborro"] @@ -38,38 +37,6 @@ FingerprintGrowComponent = fingerprint_grow_ns.class_( "FingerprintGrowComponent", cg.PollingComponent, uart.UARTDevice ) -FingerScanStartTrigger = fingerprint_grow_ns.class_( - "FingerScanStartTrigger", automation.Trigger.template() -) - -FingerScanMatchedTrigger = fingerprint_grow_ns.class_( - "FingerScanMatchedTrigger", automation.Trigger.template(cg.uint16, cg.uint16) -) - -FingerScanUnmatchedTrigger = fingerprint_grow_ns.class_( - "FingerScanUnmatchedTrigger", automation.Trigger.template() -) - -FingerScanMisplacedTrigger = fingerprint_grow_ns.class_( - "FingerScanMisplacedTrigger", automation.Trigger.template() -) - -FingerScanInvalidTrigger = fingerprint_grow_ns.class_( - "FingerScanInvalidTrigger", automation.Trigger.template() -) - -EnrollmentScanTrigger = fingerprint_grow_ns.class_( - "EnrollmentScanTrigger", automation.Trigger.template(cg.uint8, cg.uint16) -) - -EnrollmentDoneTrigger = fingerprint_grow_ns.class_( - "EnrollmentDoneTrigger", automation.Trigger.template(cg.uint16) -) - -EnrollmentFailedTrigger = fingerprint_grow_ns.class_( - "EnrollmentFailedTrigger", automation.Trigger.template(cg.uint16) -) - EnrollmentAction = fingerprint_grow_ns.class_("EnrollmentAction", automation.Action) CancelEnrollmentAction = fingerprint_grow_ns.class_( "CancelEnrollmentAction", automation.Action @@ -125,62 +92,22 @@ CONFIG_SCHEMA = cv.All( ): cv.positive_time_period_milliseconds, cv.Optional(CONF_PASSWORD): cv.uint32_t, cv.Optional(CONF_NEW_PASSWORD): cv.uint32_t, - cv.Optional(CONF_ON_FINGER_SCAN_START): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - FingerScanStartTrigger - ), - } - ), + cv.Optional(CONF_ON_FINGER_SCAN_START): automation.validate_automation({}), cv.Optional(CONF_ON_FINGER_SCAN_MATCHED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - FingerScanMatchedTrigger - ), - } + {} ), cv.Optional(CONF_ON_FINGER_SCAN_UNMATCHED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - FingerScanUnmatchedTrigger - ), - } + {} ), cv.Optional(CONF_ON_FINGER_SCAN_MISPLACED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - FingerScanMisplacedTrigger - ), - } + {} ), cv.Optional(CONF_ON_FINGER_SCAN_INVALID): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - FingerScanInvalidTrigger - ), - } - ), - cv.Optional(CONF_ON_ENROLLMENT_SCAN): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - EnrollmentScanTrigger - ), - } - ), - cv.Optional(CONF_ON_ENROLLMENT_DONE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - EnrollmentDoneTrigger - ), - } - ), - cv.Optional(CONF_ON_ENROLLMENT_FAILED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - EnrollmentFailedTrigger - ), - } + {} ), + cv.Optional(CONF_ON_ENROLLMENT_SCAN): automation.validate_automation({}), + cv.Optional(CONF_ON_ENROLLMENT_DONE): automation.validate_automation({}), + cv.Optional(CONF_ON_ENROLLMENT_FAILED): automation.validate_automation({}), } ) .extend(cv.polling_component_schema("500ms")) @@ -214,40 +141,43 @@ async def to_code(config): cg.add(var.set_idle_period_to_sleep_ms(idle_period_to_sleep_ms)) for conf in config.get(CONF_ON_FINGER_SCAN_START, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - + await automation.build_callback_automation( + var, "add_on_finger_scan_start_callback", [], conf + ) for conf in config.get(CONF_ON_FINGER_SCAN_MATCHED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.uint16, "finger_id"), (cg.uint16, "confidence")], conf + await automation.build_callback_automation( + var, + "add_on_finger_scan_matched_callback", + [(cg.uint16, "finger_id"), (cg.uint16, "confidence")], + conf, ) - for conf in config.get(CONF_ON_FINGER_SCAN_UNMATCHED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - + await automation.build_callback_automation( + var, "add_on_finger_scan_unmatched_callback", [], conf + ) for conf in config.get(CONF_ON_FINGER_SCAN_MISPLACED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - + await automation.build_callback_automation( + var, "add_on_finger_scan_misplaced_callback", [], conf + ) for conf in config.get(CONF_ON_FINGER_SCAN_INVALID, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - + await automation.build_callback_automation( + var, "add_on_finger_scan_invalid_callback", [], conf + ) for conf in config.get(CONF_ON_ENROLLMENT_SCAN, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.uint8, "scan_num"), (cg.uint16, "finger_id")], conf + await automation.build_callback_automation( + var, + "add_on_enrollment_scan_callback", + [(cg.uint8, "scan_num"), (cg.uint16, "finger_id")], + conf, ) - for conf in config.get(CONF_ON_ENROLLMENT_DONE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.uint16, "finger_id")], conf) - + await automation.build_callback_automation( + var, "add_on_enrollment_done_callback", [(cg.uint16, "finger_id")], conf + ) for conf in config.get(CONF_ON_ENROLLMENT_FAILED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.uint16, "finger_id")], conf) + await automation.build_callback_automation( + var, "add_on_enrollment_failed_callback", [(cg.uint16, "finger_id")], conf + ) @automation.register_action( diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.h b/esphome/components/fingerprint_grow/fingerprint_grow.h index 63839534f6..947c701c98 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.h +++ b/esphome/components/fingerprint_grow/fingerprint_grow.h @@ -210,64 +210,6 @@ class FingerprintGrowComponent : public PollingComponent, public uart::UARTDevic CallbackManager enrollment_failed_callback_; }; -class FingerScanStartTrigger : public Trigger<> { - public: - explicit FingerScanStartTrigger(FingerprintGrowComponent *parent) { - parent->add_on_finger_scan_start_callback([this]() { this->trigger(); }); - } -}; - -class FingerScanMatchedTrigger : public Trigger { - public: - explicit FingerScanMatchedTrigger(FingerprintGrowComponent *parent) { - parent->add_on_finger_scan_matched_callback( - [this](uint16_t finger_id, uint16_t confidence) { this->trigger(finger_id, confidence); }); - } -}; - -class FingerScanUnmatchedTrigger : public Trigger<> { - public: - explicit FingerScanUnmatchedTrigger(FingerprintGrowComponent *parent) { - parent->add_on_finger_scan_unmatched_callback([this]() { this->trigger(); }); - } -}; - -class FingerScanMisplacedTrigger : public Trigger<> { - public: - explicit FingerScanMisplacedTrigger(FingerprintGrowComponent *parent) { - parent->add_on_finger_scan_misplaced_callback([this]() { this->trigger(); }); - } -}; - -class FingerScanInvalidTrigger : public Trigger<> { - public: - explicit FingerScanInvalidTrigger(FingerprintGrowComponent *parent) { - parent->add_on_finger_scan_invalid_callback([this]() { this->trigger(); }); - } -}; - -class EnrollmentScanTrigger : public Trigger { - public: - explicit EnrollmentScanTrigger(FingerprintGrowComponent *parent) { - parent->add_on_enrollment_scan_callback( - [this](uint8_t scan_num, uint16_t finger_id) { this->trigger(scan_num, finger_id); }); - } -}; - -class EnrollmentDoneTrigger : public Trigger { - public: - explicit EnrollmentDoneTrigger(FingerprintGrowComponent *parent) { - parent->add_on_enrollment_done_callback([this](uint16_t finger_id) { this->trigger(finger_id); }); - } -}; - -class EnrollmentFailedTrigger : public Trigger { - public: - explicit EnrollmentFailedTrigger(FingerprintGrowComponent *parent) { - parent->add_on_enrollment_failed_callback([this](uint16_t finger_id) { this->trigger(finger_id); }); - } -}; - template class EnrollmentAction : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, finger_id) From a95f9f41fb418a66d9d3d550b69aa29d1fc303ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:22:58 -1000 Subject: [PATCH 037/160] [ltr_als_ps] Migrate triggers to callback automation (#15224) --- esphome/components/ltr_als_ps/ltr_als_ps.h | 36 +++++----------------- esphome/components/ltr_als_ps/sensor.py | 33 ++++++-------------- 2 files changed, 18 insertions(+), 51 deletions(-) diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.h b/esphome/components/ltr_als_ps/ltr_als_ps.h index 2e24a14283..8aa5c9f24b 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.h +++ b/esphome/components/ltr_als_ps/ltr_als_ps.h @@ -58,6 +58,14 @@ class LTRAlsPsComponent : public PollingComponent, public i2c::I2CDevice { void set_actual_integration_time_sensor(sensor::Sensor *sensor) { this->actual_integration_time_sensor_ = sensor; } void set_proximity_counts_sensor(sensor::Sensor *sensor) { this->proximity_counts_sensor_ = sensor; } + template void add_on_ps_high_trigger_callback(F &&callback) { + this->on_ps_high_trigger_callback_.add(std::forward(callback)); + } + + template void add_on_ps_low_trigger_callback(F &&callback) { + this->on_ps_low_trigger_callback_.add(std::forward(callback)); + } + protected: // // Internal state machine, used to split all the actions into @@ -151,36 +159,8 @@ class LTRAlsPsComponent : public PollingComponent, public i2c::I2CDevice { } bool is_any_ps_sensor_enabled_() const { return this->proximity_counts_sensor_ != nullptr; } - // - // Trigger section for the automations - // - friend class LTRPsHighTrigger; - friend class LTRPsLowTrigger; - CallbackManager on_ps_high_trigger_callback_; CallbackManager on_ps_low_trigger_callback_; - - template void add_on_ps_high_trigger_callback_(F &&callback) { - this->on_ps_high_trigger_callback_.add(std::forward(callback)); - } - - template void add_on_ps_low_trigger_callback_(F &&callback) { - this->on_ps_low_trigger_callback_.add(std::forward(callback)); - } -}; - -class LTRPsHighTrigger : public Trigger<> { - public: - explicit LTRPsHighTrigger(LTRAlsPsComponent *parent) { - parent->add_on_ps_high_trigger_callback_([this]() { this->trigger(); }); - } -}; - -class LTRPsLowTrigger : public Trigger<> { - public: - explicit LTRPsLowTrigger(LTRAlsPsComponent *parent) { - parent->add_on_ps_low_trigger_callback_([this]() { this->trigger(); }); - } }; } // namespace ltr_als_ps } // namespace esphome diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 0dbcff1bfb..57503772a1 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_INTEGRATION_TIME, CONF_NAME, CONF_REPEAT, - CONF_TRIGGER_ID, CONF_TYPE, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, @@ -93,11 +92,6 @@ PS_GAINS = { "64X": PsGain.PS_GAIN_64, } -LTRPsHighTrigger = ltr_als_ps_ns.class_( - "LTRPsHighTrigger", automation.Trigger.template() -) -LTRPsLowTrigger = ltr_als_ps_ns.class_("LTRPsLowTrigger", automation.Trigger.template()) - def validate_integration_time(value): value = cv.positive_time_period_milliseconds(value).total_milliseconds @@ -143,16 +137,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.int_range( min=0, max=65535 ), - cv.Optional(CONF_ON_PS_HIGH_THRESHOLD): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LTRPsHighTrigger), - } - ), - cv.Optional(CONF_ON_PS_LOW_THRESHOLD): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LTRPsLowTrigger), - } - ), + cv.Optional(CONF_ON_PS_HIGH_THRESHOLD): automation.validate_automation({}), + cv.Optional(CONF_ON_PS_LOW_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_AMBIENT_LIGHT): cv.maybe_simple_value( sensor.sensor_schema( unit_of_measurement=UNIT_LUX, @@ -244,13 +230,14 @@ async def to_code(config): sens = await sensor.new_sensor(prox_cnt_config) cg.add(var.set_proximity_counts_sensor(sens)) - for prox_high_tr in config.get(CONF_ON_PS_HIGH_THRESHOLD, []): - trigger = cg.new_Pvariable(prox_high_tr[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], prox_high_tr) - - for prox_low_tr in config.get(CONF_ON_PS_LOW_THRESHOLD, []): - trigger = cg.new_Pvariable(prox_low_tr[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], prox_low_tr) + for conf in config.get(CONF_ON_PS_HIGH_THRESHOLD, []): + await automation.build_callback_automation( + var, "add_on_ps_high_trigger_callback", [], conf + ) + for conf in config.get(CONF_ON_PS_LOW_THRESHOLD, []): + await automation.build_callback_automation( + var, "add_on_ps_low_trigger_callback", [], conf + ) cg.add(var.set_ltr_type(config[CONF_TYPE])) From a73c67e4763c971573e89c0d5b4f7e6971ef2341 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:23:17 -1000 Subject: [PATCH 038/160] [ltr501] Migrate triggers to callback automation (#15225) --- esphome/components/ltr501/ltr501.h | 36 +++++++---------------------- esphome/components/ltr501/sensor.py | 31 ++++++++----------------- 2 files changed, 18 insertions(+), 49 deletions(-) diff --git a/esphome/components/ltr501/ltr501.h b/esphome/components/ltr501/ltr501.h index 2bd838a0fe..2b91463108 100644 --- a/esphome/components/ltr501/ltr501.h +++ b/esphome/components/ltr501/ltr501.h @@ -58,6 +58,14 @@ class LTRAlsPs501Component : public PollingComponent, public i2c::I2CDevice { void set_actual_integration_time_sensor(sensor::Sensor *sensor) { this->actual_integration_time_sensor_ = sensor; } void set_proximity_counts_sensor(sensor::Sensor *sensor) { this->proximity_counts_sensor_ = sensor; } + template void add_on_ps_high_trigger_callback(F &&callback) { + this->on_ps_high_trigger_callback_.add(std::forward(callback)); + } + + template void add_on_ps_low_trigger_callback(F &&callback) { + this->on_ps_low_trigger_callback_.add(std::forward(callback)); + } + protected: // // Internal state machine, used to split all the actions into @@ -151,36 +159,8 @@ class LTRAlsPs501Component : public PollingComponent, public i2c::I2CDevice { } bool is_any_ps_sensor_enabled_() const { return this->proximity_counts_sensor_ != nullptr; } - // - // Trigger section for the automations - // - friend class LTRPsHighTrigger; - friend class LTRPsLowTrigger; - CallbackManager on_ps_high_trigger_callback_; CallbackManager on_ps_low_trigger_callback_; - - template void add_on_ps_high_trigger_callback_(F &&callback) { - this->on_ps_high_trigger_callback_.add(std::forward(callback)); - } - - template void add_on_ps_low_trigger_callback_(F &&callback) { - this->on_ps_low_trigger_callback_.add(std::forward(callback)); - } -}; - -class LTRPsHighTrigger : public Trigger<> { - public: - explicit LTRPsHighTrigger(LTRAlsPs501Component *parent) { - parent->add_on_ps_high_trigger_callback_([this]() { this->trigger(); }); - } -}; - -class LTRPsLowTrigger : public Trigger<> { - public: - explicit LTRPsLowTrigger(LTRAlsPs501Component *parent) { - parent->add_on_ps_low_trigger_callback_([this]() { this->trigger(); }); - } }; } // namespace ltr501 } // namespace esphome diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index adaf669a72..712810222c 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_INTEGRATION_TIME, CONF_NAME, CONF_REPEAT, - CONF_TRIGGER_ID, CONF_TYPE, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_ILLUMINANCE, @@ -87,9 +86,6 @@ PS_GAINS = { "16X": PsGain.PS_GAIN_16, } -LTRPsHighTrigger = ltr501_ns.class_("LTRPsHighTrigger", automation.Trigger.template()) -LTRPsLowTrigger = ltr501_ns.class_("LTRPsLowTrigger", automation.Trigger.template()) - def validate_integration_time(value): value = cv.positive_time_period_milliseconds(value).total_milliseconds @@ -146,16 +142,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.int_range( min=0, max=65535 ), - cv.Optional(CONF_ON_PS_HIGH_THRESHOLD): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LTRPsHighTrigger), - } - ), - cv.Optional(CONF_ON_PS_LOW_THRESHOLD): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LTRPsLowTrigger), - } - ), + cv.Optional(CONF_ON_PS_HIGH_THRESHOLD): automation.validate_automation({}), + cv.Optional(CONF_ON_PS_LOW_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_AMBIENT_LIGHT): cv.maybe_simple_value( sensor.sensor_schema( unit_of_measurement=UNIT_LUX, @@ -252,13 +240,14 @@ async def to_code(config): sens = await sensor.new_sensor(prox_cnt_config) cg.add(var.set_proximity_counts_sensor(sens)) - for prox_high_tr in config.get(CONF_ON_PS_HIGH_THRESHOLD, []): - trigger = cg.new_Pvariable(prox_high_tr[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], prox_high_tr) - - for prox_low_tr in config.get(CONF_ON_PS_LOW_THRESHOLD, []): - trigger = cg.new_Pvariable(prox_low_tr[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], prox_low_tr) + for conf in config.get(CONF_ON_PS_HIGH_THRESHOLD, []): + await automation.build_callback_automation( + var, "add_on_ps_high_trigger_callback", [], conf + ) + for conf in config.get(CONF_ON_PS_LOW_THRESHOLD, []): + await automation.build_callback_automation( + var, "add_on_ps_low_trigger_callback", [], conf + ) cg.add(var.set_ltr_type(config[CONF_TYPE])) From f5cd1e5e76831637ecf987439e205243a32c1fb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:23:26 -1000 Subject: [PATCH 039/160] [ld2450] Fix flaky integration test race condition (#15226) --- tests/integration/test_uart_mock_ld2450.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_uart_mock_ld2450.py b/tests/integration/test_uart_mock_ld2450.py index b1aa2f6952..2469273e0a 100644 --- a/tests/integration/test_uart_mock_ld2450.py +++ b/tests/integration/test_uart_mock_ld2450.py @@ -83,11 +83,18 @@ async def test_uart_mock_ld2450( ], ) - # Signal when we see recovery frame values (target 1 distance ≈ 500mm) + # Signal when we see all recovery frame values + # Must wait for ALL values to avoid race where some arrive after the waiter fires recovery_received = collector.add_waiter( lambda: ( pytest.approx(500.0, abs=1.0) in collector.sensor_states["target_1_distance"] + and pytest.approx(300.0) in collector.sensor_states["target_1_x"] + and pytest.approx(400.0) in collector.sensor_states["target_1_y"] + and pytest.approx(30.0) in collector.sensor_states["target_1_speed"] + and pytest.approx(1.0) in collector.sensor_states["target_count"] + and pytest.approx(1.0) in collector.sensor_states["moving_target_count"] + and pytest.approx(0.0) in collector.sensor_states["still_target_count"] ) ) From d77bf23c76b7257d20cd0c9541eabcf2613adf69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:23:37 -1000 Subject: [PATCH 040/160] [nextion] Migrate triggers to callback automation (#15227) --- esphome/components/nextion/automation.h | 44 ------------ esphome/components/nextion/display.py | 90 +++++++------------------ 2 files changed, 25 insertions(+), 109 deletions(-) diff --git a/esphome/components/nextion/automation.h b/esphome/components/nextion/automation.h index 8e85e15823..9f52507d67 100644 --- a/esphome/components/nextion/automation.h +++ b/esphome/components/nextion/automation.h @@ -5,50 +5,6 @@ namespace esphome { namespace nextion { -class BufferOverflowTrigger : public Trigger<> { - public: - explicit BufferOverflowTrigger(Nextion *nextion) { - nextion->add_buffer_overflow_event_callback([this]() { this->trigger(); }); - } -}; - -class SetupTrigger : public Trigger<> { - public: - explicit SetupTrigger(Nextion *nextion) { - nextion->add_setup_state_callback([this]() { this->trigger(); }); - } -}; - -class SleepTrigger : public Trigger<> { - public: - explicit SleepTrigger(Nextion *nextion) { - nextion->add_sleep_state_callback([this]() { this->trigger(); }); - } -}; - -class WakeTrigger : public Trigger<> { - public: - explicit WakeTrigger(Nextion *nextion) { - nextion->add_wake_state_callback([this]() { this->trigger(); }); - } -}; - -class PageTrigger : public Trigger { - public: - explicit PageTrigger(Nextion *nextion) { - nextion->add_new_page_callback([this](const uint8_t page_id) { this->trigger(page_id); }); - } -}; - -class TouchTrigger : public Trigger { - public: - explicit TouchTrigger(Nextion *nextion) { - nextion->add_touch_event_callback([this](uint8_t page_id, uint8_t component_id, bool touch_event) { - this->trigger(page_id, component_id, touch_event); - }); - } -}; - template class NextionSetBrightnessAction : public Action { public: explicit NextionSetBrightnessAction(Nextion *component) : component_(component) {} diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 5b2dfc488d..506eb1202b 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -2,13 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import display, esp32, uart import esphome.config_validation as cv -from esphome.const import ( - CONF_BRIGHTNESS, - CONF_ID, - CONF_LAMBDA, - CONF_ON_TOUCH, - CONF_TRIGGER_ID, -) +from esphome.const import CONF_BRIGHTNESS, CONF_ID, CONF_LAMBDA, CONF_ON_TOUCH from esphome.core import CORE, TimePeriod from . import ( # noqa: F401 pylint: disable=unused-import @@ -55,14 +49,6 @@ def AUTO_LOAD() -> list[str]: NextionSetBrightnessAction = nextion_ns.class_( "NextionSetBrightnessAction", automation.Action ) -SetupTrigger = nextion_ns.class_("SetupTrigger", automation.Trigger.template()) -SleepTrigger = nextion_ns.class_("SleepTrigger", automation.Trigger.template()) -WakeTrigger = nextion_ns.class_("WakeTrigger", automation.Trigger.template()) -PageTrigger = nextion_ns.class_("PageTrigger", automation.Trigger.template()) -TouchTrigger = nextion_ns.class_("TouchTrigger", automation.Trigger.template()) -BufferOverflowTrigger = nextion_ns.class_( - "BufferOverflowTrigger", automation.Trigger.template() -) def _validate_tft_upload(config): @@ -101,38 +87,12 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_MAX_COMMANDS_PER_LOOP): cv.uint16_t, cv.Optional(CONF_MAX_QUEUE_SIZE): cv.positive_int, - cv.Optional(CONF_ON_BUFFER_OVERFLOW): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - BufferOverflowTrigger - ), - } - ), - cv.Optional(CONF_ON_PAGE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PageTrigger), - } - ), - cv.Optional(CONF_ON_SETUP): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SetupTrigger), - } - ), - cv.Optional(CONF_ON_SLEEP): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SleepTrigger), - } - ), - cv.Optional(CONF_ON_TOUCH): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TouchTrigger), - } - ), - cv.Optional(CONF_ON_WAKE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(WakeTrigger), - } - ), + cv.Optional(CONF_ON_BUFFER_OVERFLOW): automation.validate_automation({}), + cv.Optional(CONF_ON_PAGE): automation.validate_automation({}), + cv.Optional(CONF_ON_SETUP): automation.validate_automation({}), + cv.Optional(CONF_ON_SLEEP): automation.validate_automation({}), + cv.Optional(CONF_ON_TOUCH): automation.validate_automation({}), + cv.Optional(CONF_ON_WAKE): automation.validate_automation({}), cv.Optional(CONF_SKIP_CONNECTION_HANDSHAKE, default=False): cv.boolean, cv.Optional(CONF_STARTUP_OVERRIDE_MS, default="8000ms"): cv.All( cv.positive_time_period_milliseconds, @@ -273,25 +233,25 @@ async def to_code(config): await display.register_display(var, config) for conf in config.get(CONF_ON_SETUP, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - + await automation.build_callback_automation( + var, "add_setup_state_callback", [], conf + ) for conf in config.get(CONF_ON_SLEEP, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - + await automation.build_callback_automation( + var, "add_sleep_state_callback", [], conf + ) for conf in config.get(CONF_ON_WAKE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - + await automation.build_callback_automation( + var, "add_wake_state_callback", [], conf + ) for conf in config.get(CONF_ON_PAGE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.uint8, "x")], conf) - + await automation.build_callback_automation( + var, "add_new_page_callback", [(cg.uint8, "x")], conf + ) for conf in config.get(CONF_ON_TOUCH, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, + await automation.build_callback_automation( + var, + "add_touch_event_callback", [ (cg.uint8, "page_id"), (cg.uint8, "component_id"), @@ -299,7 +259,7 @@ async def to_code(config): ], conf, ) - for conf in config.get(CONF_ON_BUFFER_OVERFLOW, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_buffer_overflow_event_callback", [], conf + ) From 2f3c21c7c16b37d2ad1f57e4d90883129a50c86c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:23:50 -1000 Subject: [PATCH 041/160] [ezo] Migrate triggers to callback automation (#15228) --- esphome/components/ezo/automation.h | 53 ---------------- esphome/components/ezo/sensor.py | 94 ++++++++--------------------- 2 files changed, 25 insertions(+), 122 deletions(-) delete mode 100644 esphome/components/ezo/automation.h diff --git a/esphome/components/ezo/automation.h b/esphome/components/ezo/automation.h deleted file mode 100644 index a4a6fa3014..0000000000 --- a/esphome/components/ezo/automation.h +++ /dev/null @@ -1,53 +0,0 @@ -#pragma once -#include - -#include "esphome/core/automation.h" -#include "ezo.h" - -namespace esphome { -namespace ezo { - -class LedTrigger : public Trigger { - public: - explicit LedTrigger(EZOSensor *ezo) { - ezo->add_led_state_callback([this](bool value) { this->trigger(value); }); - } -}; - -class CustomTrigger : public Trigger { - public: - explicit CustomTrigger(EZOSensor *ezo) { - ezo->add_custom_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -class TTrigger : public Trigger { - public: - explicit TTrigger(EZOSensor *ezo) { - ezo->add_t_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -class CalibrationTrigger : public Trigger { - public: - explicit CalibrationTrigger(EZOSensor *ezo) { - ezo->add_calibration_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -class SlopeTrigger : public Trigger { - public: - explicit SlopeTrigger(EZOSensor *ezo) { - ezo->add_slope_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -class DeviceInformationTrigger : public Trigger { - public: - explicit DeviceInformationTrigger(EZOSensor *ezo) { - ezo->add_device_infomation_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -} // namespace ezo -} // namespace esphome diff --git a/esphome/components/ezo/sensor.py b/esphome/components/ezo/sensor.py index cf240faec3..7c81f9c848 100644 --- a/esphome/components/ezo/sensor.py +++ b/esphome/components/ezo/sensor.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TRIGGER_ID +from esphome.const import CONF_ID CODEOWNERS = ["@ssieb"] @@ -21,61 +21,16 @@ EZOSensor = ezo_ns.class_( "EZOSensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) -CustomTrigger = ezo_ns.class_( - "CustomTrigger", automation.Trigger.template(cg.std_string) -) - - -TTrigger = ezo_ns.class_("TTrigger", automation.Trigger.template(cg.std_string)) - -SlopeTrigger = ezo_ns.class_("SlopeTrigger", automation.Trigger.template(cg.std_string)) - -CalibrationTrigger = ezo_ns.class_( - "CalibrationTrigger", automation.Trigger.template(cg.std_string) -) - -DeviceInformationTrigger = ezo_ns.class_( - "DeviceInformationTrigger", automation.Trigger.template(cg.std_string) -) - -LedTrigger = ezo_ns.class_("LedTrigger", automation.Trigger.template(cg.bool_)) - CONFIG_SCHEMA = ( sensor.sensor_schema(EZOSensor) .extend( { - cv.Optional(CONF_ON_CUSTOM): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CustomTrigger), - } - ), - cv.Optional(CONF_ON_CALIBRATION): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CalibrationTrigger), - } - ), - cv.Optional(CONF_ON_SLOPE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SlopeTrigger), - } - ), - cv.Optional(CONF_ON_T): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TTrigger), - } - ), - cv.Optional(CONF_ON_DEVICE_INFORMATION): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DeviceInformationTrigger - ), - } - ), - cv.Optional(CONF_ON_LED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LedTrigger), - } - ), + cv.Optional(CONF_ON_CUSTOM): automation.validate_automation({}), + cv.Optional(CONF_ON_CALIBRATION): automation.validate_automation({}), + cv.Optional(CONF_ON_SLOPE): automation.validate_automation({}), + cv.Optional(CONF_ON_T): automation.validate_automation({}), + cv.Optional(CONF_ON_DEVICE_INFORMATION): automation.validate_automation({}), + cv.Optional(CONF_ON_LED): automation.validate_automation({}), } ) .extend(cv.polling_component_schema("60s")) @@ -90,25 +45,26 @@ async def to_code(config): await i2c.register_i2c_device(var, config) for conf in config.get(CONF_ON_CUSTOM, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) - + await automation.build_callback_automation( + var, "add_custom_callback", [(cg.std_string, "x")], conf + ) for conf in config.get(CONF_ON_LED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(bool, "x")], conf) - + await automation.build_callback_automation( + var, "add_led_state_callback", [(bool, "x")], conf + ) for conf in config.get(CONF_ON_DEVICE_INFORMATION, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) - + await automation.build_callback_automation( + var, "add_device_infomation_callback", [(cg.std_string, "x")], conf + ) for conf in config.get(CONF_ON_SLOPE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) - + await automation.build_callback_automation( + var, "add_slope_callback", [(cg.std_string, "x")], conf + ) for conf in config.get(CONF_ON_CALIBRATION, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) - + await automation.build_callback_automation( + var, "add_calibration_callback", [(cg.std_string, "x")], conf + ) for conf in config.get(CONF_ON_T, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + await automation.build_callback_automation( + var, "add_t_callback", [(cg.std_string, "x")], conf + ) From 39509265bc76a0eadce17c9e28a4b032fc3597fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:24:03 -1000 Subject: [PATCH 042/160] [haier] Migrate triggers to callback automation (#15229) --- esphome/components/haier/climate.py | 62 ++++++++------------------ esphome/components/haier/haier_base.h | 7 --- esphome/components/haier/hon_climate.h | 16 ------- 3 files changed, 18 insertions(+), 67 deletions(-) diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index caaaa18dd6..9c2c999f25 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -22,7 +22,6 @@ from esphome.const import ( CONF_SUPPORTED_SWING_MODES, CONF_TARGET_TEMPERATURE, CONF_TEMPERATURE_STEP, - CONF_TRIGGER_ID, CONF_VISUAL, CONF_WIFI, ) @@ -122,21 +121,6 @@ SUPPORTED_HON_CONTROL_METHODS = { "SET_SINGLE_PARAMETER": HonControlMethod.SET_SINGLE_PARAMETER, } -HaierAlarmStartTrigger = haier_ns.class_( - "HaierAlarmStartTrigger", - automation.Trigger.template(cg.uint8, cg.const_char_ptr), -) - -HaierAlarmEndTrigger = haier_ns.class_( - "HaierAlarmEndTrigger", - automation.Trigger.template(cg.uint8, cg.const_char_ptr), -) - -StatusMessageTrigger = haier_ns.class_( - "StatusMessageTrigger", - automation.Trigger.template(cg.const_char_ptr, cg.size_t), -) - def validate_visual(config): if CONF_VISUAL in config: @@ -203,13 +187,7 @@ def _base_config_schema(class_: MockObjClass) -> cv.Schema: cv.Optional( CONF_ANSWER_TIMEOUT, ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_STATUS_MESSAGE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - StatusMessageTrigger - ), - } - ), + cv.Optional(CONF_ON_STATUS_MESSAGE): automation.validate_automation({}), } ) .extend(uart.UART_DEVICE_SCHEMA) @@ -264,19 +242,9 @@ CONFIG_SCHEMA = cv.All( f"The {CONF_OUTDOOR_TEMPERATURE} option is deprecated, use a sensor for a haier platform instead" ), cv.Optional(CONF_ON_ALARM_START): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - HaierAlarmStartTrigger - ), - } - ), - cv.Optional(CONF_ON_ALARM_END): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - HaierAlarmEndTrigger - ), - } + {} ), + cv.Optional(CONF_ON_ALARM_END): automation.validate_automation({}), } ), }, @@ -530,19 +498,25 @@ async def to_code(config): var.set_status_message_header_size(config[CONF_STATUS_MESSAGE_HEADER_SIZE]) ) for conf in config.get(CONF_ON_ALARM_START, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.uint8, "code"), (cg.const_char_ptr, "message")], conf + await automation.build_callback_automation( + var, + "add_alarm_start_callback", + [(cg.uint8, "code"), (cg.const_char_ptr, "message")], + conf, ) for conf in config.get(CONF_ON_ALARM_END, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.uint8, "code"), (cg.const_char_ptr, "message")], conf + await automation.build_callback_automation( + var, + "add_alarm_end_callback", + [(cg.uint8, "code"), (cg.const_char_ptr, "message")], + conf, ) for conf in config.get(CONF_ON_STATUS_MESSAGE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.const_char_ptr, "data"), (cg.size_t, "data_size")], conf + await automation.build_callback_automation( + var, + "add_status_message_callback", + [(cg.const_char_ptr, "data"), (cg.size_t, "data_size")], + conf, ) # https://github.com/paveldn/HaierProtocol cg.add_library("pavlodn/HaierProtocol", "0.9.31") diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index 87aa1d65ef..0c416623c0 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -177,12 +177,5 @@ class HaierClimateBase : public esphome::Component, ESPPreferenceObject base_rtc_; }; -class StatusMessageTrigger : public Trigger { - public: - explicit StatusMessageTrigger(HaierClimateBase *parent) { - parent->add_status_message_callback([this](const char *data, size_t data_size) { this->trigger(data, data_size); }); - } -}; - } // namespace haier } // namespace esphome diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index 7c48a3748b..7a87f27b66 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -200,21 +200,5 @@ class HonClimate : public HaierClimateBase { SwitchState quiet_mode_state_{SwitchState::OFF}; }; -class HaierAlarmStartTrigger : public Trigger { - public: - explicit HaierAlarmStartTrigger(HonClimate *parent) { - parent->add_alarm_start_callback( - [this](uint8_t alarm_code, const char *alarm_message) { this->trigger(alarm_code, alarm_message); }); - } -}; - -class HaierAlarmEndTrigger : public Trigger { - public: - explicit HaierAlarmEndTrigger(HonClimate *parent) { - parent->add_alarm_end_callback( - [this](uint8_t alarm_code, const char *alarm_message) { this->trigger(alarm_code, alarm_message); }); - } -}; - } // namespace haier } // namespace esphome From f9d41bd36adf4923f0509db82da47c321ffafcac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:24:15 -1000 Subject: [PATCH 043/160] [modbus_controller] Migrate triggers to callback automation (#15230) --- .../components/modbus_controller/__init__.py | 64 ++++++------------- .../components/modbus_controller/automation.h | 35 ---------- 2 files changed, 19 insertions(+), 80 deletions(-) delete mode 100644 esphome/components/modbus_controller/automation.h diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index aea79b2053..dfc43bf23b 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -5,14 +5,7 @@ import esphome.codegen as cg from esphome.components import modbus from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import ( - CONF_ADDRESS, - CONF_ID, - CONF_LAMBDA, - CONF_NAME, - CONF_OFFSET, - CONF_TRIGGER_ID, -) +from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET from esphome.cpp_helpers import logging from .const import ( @@ -135,17 +128,6 @@ CPP_TYPE_REGISTER_MAP = { "FP32_R": cg.float_, } -ModbusCommandSentTrigger = modbus_controller_ns.class_( - "ModbusCommandSentTrigger", automation.Trigger.template(cg.int_, cg.int_) -) - -ModbusOnlineTrigger = modbus_controller_ns.class_( - "ModbusOnlineTrigger", automation.Trigger.template(cg.int_, cg.int_) -) - -ModbusOfflineTrigger = modbus_controller_ns.class_( - "ModbusOfflineTrigger", automation.Trigger.template(cg.int_, cg.int_) -) _LOGGER = logging.getLogger(__name__) @@ -182,23 +164,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_SERVER_REGISTERS, ): cv.ensure_list(ModbusServerRegisterSchema), - cv.Optional(CONF_ON_COMMAND_SENT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ModbusCommandSentTrigger - ), - } - ), - cv.Optional(CONF_ON_ONLINE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ModbusOnlineTrigger), - } - ), - cv.Optional(CONF_ON_OFFLINE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ModbusOfflineTrigger), - } - ), + cv.Optional(CONF_ON_COMMAND_SENT): automation.validate_automation({}), + cv.Optional(CONF_ON_ONLINE): automation.validate_automation({}), + cv.Optional(CONF_ON_OFFLINE): automation.validate_automation({}), } ) .extend(cv.polling_component_schema("60s")) @@ -363,19 +331,25 @@ async def to_code(config): cg.add(var.add_server_register(server_register_var)) await register_modbus_device(var, config) for conf in config.get(CONF_ON_COMMAND_SENT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.int_, "function_code"), (cg.int_, "address")], conf + await automation.build_callback_automation( + var, + "add_on_command_sent_callback", + [(cg.int_, "function_code"), (cg.int_, "address")], + conf, ) for conf in config.get(CONF_ON_ONLINE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.int_, "function_code"), (cg.int_, "address")], conf + await automation.build_callback_automation( + var, + "add_on_online_callback", + [(cg.int_, "function_code"), (cg.int_, "address")], + conf, ) for conf in config.get(CONF_ON_OFFLINE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.int_, "function_code"), (cg.int_, "address")], conf + await automation.build_callback_automation( + var, + "add_on_offline_callback", + [(cg.int_, "function_code"), (cg.int_, "address")], + conf, ) diff --git a/esphome/components/modbus_controller/automation.h b/esphome/components/modbus_controller/automation.h deleted file mode 100644 index b3338192cc..0000000000 --- a/esphome/components/modbus_controller/automation.h +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/modbus_controller/modbus_controller.h" - -namespace esphome { -namespace modbus_controller { - -class ModbusCommandSentTrigger : public Trigger { - public: - ModbusCommandSentTrigger(ModbusController *a_modbuscontroller) { - a_modbuscontroller->add_on_command_sent_callback( - [this](int function_code, int address) { this->trigger(function_code, address); }); - } -}; - -class ModbusOnlineTrigger : public Trigger { - public: - ModbusOnlineTrigger(ModbusController *a_modbuscontroller) { - a_modbuscontroller->add_on_online_callback( - [this](int function_code, int address) { this->trigger(function_code, address); }); - } -}; - -class ModbusOfflineTrigger : public Trigger { - public: - ModbusOfflineTrigger(ModbusController *a_modbuscontroller) { - a_modbuscontroller->add_on_offline_callback( - [this](int function_code, int address) { this->trigger(function_code, address); }); - } -}; - -} // namespace modbus_controller -} // namespace esphome From 0d67f91facbe654bbb634d95aa16c791ff52a3f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:24:25 -1000 Subject: [PATCH 044/160] [rf_bridge] Migrate triggers to callback automation (#15231) --- esphome/components/rf_bridge/__init__.py | 37 +++++++----------------- esphome/components/rf_bridge/rf_bridge.h | 14 --------- 2 files changed, 10 insertions(+), 41 deletions(-) diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 934f24b789..c6eb1749c3 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -12,7 +12,6 @@ from esphome.const import ( CONF_PROTOCOL, CONF_RAW, CONF_SYNC, - CONF_TRIGGER_ID, ) DEPENDENCIES = ["uart"] @@ -26,14 +25,6 @@ RFBridgeComponent = rf_bridge_ns.class_( RFBridgeData = rf_bridge_ns.struct("RFBridgeData") RFBridgeAdvancedData = rf_bridge_ns.struct("RFBridgeAdvancedData") -RFBridgeReceivedCodeTrigger = rf_bridge_ns.class_( - "RFBridgeReceivedCodeTrigger", automation.Trigger.template(RFBridgeData) -) -RFBridgeReceivedAdvancedCodeTrigger = rf_bridge_ns.class_( - "RFBridgeReceivedAdvancedCodeTrigger", - automation.Trigger.template(RFBridgeAdvancedData), -) - RFBridgeSendCodeAction = rf_bridge_ns.class_( "RFBridgeSendCodeAction", automation.Action ) @@ -65,19 +56,9 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(RFBridgeComponent), - cv.Optional(CONF_ON_CODE_RECEIVED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - RFBridgeReceivedCodeTrigger - ), - } - ), + cv.Optional(CONF_ON_CODE_RECEIVED): automation.validate_automation({}), cv.Optional(CONF_ON_ADVANCED_CODE_RECEIVED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - RFBridgeReceivedAdvancedCodeTrigger - ), - } + {} ), } ) @@ -92,13 +73,15 @@ async def to_code(config): await uart.register_uart_device(var, config) for conf in config.get(CONF_ON_CODE_RECEIVED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(RFBridgeData, "data")], conf) - + await automation.build_callback_automation( + var, "add_on_code_received_callback", [(RFBridgeData, "data")], conf + ) for conf in config.get(CONF_ON_ADVANCED_CODE_RECEIVED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(RFBridgeAdvancedData, "data")], conf + await automation.build_callback_automation( + var, + "add_on_advanced_code_received_callback", + [(RFBridgeAdvancedData, "data")], + conf, ) diff --git a/esphome/components/rf_bridge/rf_bridge.h b/esphome/components/rf_bridge/rf_bridge.h index e5780c9ebe..571ac6c385 100644 --- a/esphome/components/rf_bridge/rf_bridge.h +++ b/esphome/components/rf_bridge/rf_bridge.h @@ -77,20 +77,6 @@ class RFBridgeComponent : public uart::UARTDevice, public Component { CallbackManager advanced_data_callback_; }; -class RFBridgeReceivedCodeTrigger : public Trigger { - public: - explicit RFBridgeReceivedCodeTrigger(RFBridgeComponent *parent) { - parent->add_on_code_received_callback([this](RFBridgeData data) { this->trigger(data); }); - } -}; - -class RFBridgeReceivedAdvancedCodeTrigger : public Trigger { - public: - explicit RFBridgeReceivedAdvancedCodeTrigger(RFBridgeComponent *parent) { - parent->add_on_advanced_code_received_callback([this](const RFBridgeAdvancedData &data) { this->trigger(data); }); - } -}; - template class RFBridgeSendCodeAction : public Action { public: RFBridgeSendCodeAction(RFBridgeComponent *parent) : parent_(parent) {} From 5a8d6931a8d8a0f1fe05727e2e5d00098aa2dbb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Mar 2026 08:24:35 -1000 Subject: [PATCH 045/160] [factory_reset] Migrate FastBootTrigger to callback automation (#15232) --- esphome/components/factory_reset/__init__.py | 21 ++++++------------- .../components/factory_reset/factory_reset.h | 6 ------ 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index 5784d09ce6..20b191a2b7 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -1,10 +1,9 @@ -from esphome.automation import Trigger, build_automation, validate_automation +from esphome import automation import esphome.codegen as cg from esphome.components.esp8266 import CONF_RESTORE_FROM_FLASH, KEY_ESP8266 import esphome.config_validation as cv from esphome.const import ( CONF_ID, - CONF_TRIGGER_ID, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -18,7 +17,6 @@ CODEOWNERS = ["@anatoly-savchenkov"] factory_reset_ns = cg.esphome_ns.namespace("factory_reset") FactoryResetComponent = factory_reset_ns.class_("FactoryResetComponent", cg.Component) -FastBootTrigger = factory_reset_ns.class_("FastBootTrigger", Trigger, cg.Component) CONF_MAX_DELAY = "max_delay" CONF_RESETS_REQUIRED = "resets_required" @@ -55,11 +53,7 @@ CONFIG_SCHEMA = cv.All( ), ), cv.Optional(CONF_RESETS_REQUIRED): cv.positive_not_null_int, - cv.Optional(CONF_ON_INCREMENT): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FastBootTrigger), - } - ), + cv.Optional(CONF_ON_INCREMENT): automation.validate_automation({}), } ).extend(cv.COMPONENT_SCHEMA), _validate, @@ -88,12 +82,9 @@ async def to_code(config): ) await cg.register_component(var, config) for conf in config.get(CONF_ON_INCREMENT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await build_automation( - trigger, - [ - (cg.uint8, "x"), - (cg.uint8, "target"), - ], + await automation.build_callback_automation( + var, + "add_increment_callback", + [(cg.uint8, "x"), (cg.uint8, "target")], conf, ) diff --git a/esphome/components/factory_reset/factory_reset.h b/esphome/components/factory_reset/factory_reset.h index 34f89d73b6..41ee627c4b 100644 --- a/esphome/components/factory_reset/factory_reset.h +++ b/esphome/components/factory_reset/factory_reset.h @@ -30,12 +30,6 @@ class FactoryResetComponent : public Component { uint8_t required_count_; // The number of boot attempts before fast boot is enabled }; -class FastBootTrigger : public Trigger { - public: - explicit FastBootTrigger(FactoryResetComponent *parent) { - parent->add_increment_callback([this](uint8_t current, uint8_t target) { this->trigger(current, target); }); - } -}; } // namespace esphome::factory_reset #endif // !defined(USE_RP2040) && !defined(USE_HOST) From 810c046cc68dba48b3748f4a60de2049146beee7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:25:38 -0400 Subject: [PATCH 046/160] [multiple] Fix misc hardware register bugs (#15208) --- esphome/components/mcp23008/mcp23008.cpp | 4 +++- esphome/components/mcp23017/mcp23017.cpp | 6 ++++-- esphome/components/mcp23s08/mcp23s08.cpp | 15 ++++++++++----- esphome/components/mcp23s17/mcp23s17.cpp | 22 +++++++++++++--------- esphome/components/mmc5603/mmc5603.cpp | 6 +++--- esphome/components/sx1509/sx1509.cpp | 4 ++-- 6 files changed, 35 insertions(+), 22 deletions(-) diff --git a/esphome/components/mcp23008/mcp23008.cpp b/esphome/components/mcp23008/mcp23008.cpp index 0c34e4971a..64b120daa4 100644 --- a/esphome/components/mcp23008/mcp23008.cpp +++ b/esphome/components/mcp23008/mcp23008.cpp @@ -6,6 +6,8 @@ namespace mcp23008 { static const char *const TAG = "mcp23008"; +static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin + void MCP23008::setup() { uint8_t iocon; if (!this->read_reg(mcp23x08_base::MCP23X08_IOCON, &iocon)) { @@ -18,7 +20,7 @@ void MCP23008::setup() { if (this->open_drain_ints_) { // enable open-drain interrupt pins, 3.3V-safe - this->write_reg(mcp23x08_base::MCP23X08_IOCON, 0x04); + this->write_reg(mcp23x08_base::MCP23X08_IOCON, iocon | IOCON_ODR); } } diff --git a/esphome/components/mcp23017/mcp23017.cpp b/esphome/components/mcp23017/mcp23017.cpp index 1ad2036939..e14e317d44 100644 --- a/esphome/components/mcp23017/mcp23017.cpp +++ b/esphome/components/mcp23017/mcp23017.cpp @@ -6,6 +6,8 @@ namespace mcp23017 { static const char *const TAG = "mcp23017"; +static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin + void MCP23017::setup() { uint8_t iocon; if (!this->read_reg(mcp23x17_base::MCP23X17_IOCONA, &iocon)) { @@ -19,8 +21,8 @@ void MCP23017::setup() { if (this->open_drain_ints_) { // enable open-drain interrupt pins, 3.3V-safe - this->write_reg(mcp23x17_base::MCP23X17_IOCONA, 0x04); - this->write_reg(mcp23x17_base::MCP23X17_IOCONB, 0x04); + this->write_reg(mcp23x17_base::MCP23X17_IOCONA, iocon | IOCON_ODR); + this->write_reg(mcp23x17_base::MCP23X17_IOCONB, iocon | IOCON_ODR); } } diff --git a/esphome/components/mcp23s08/mcp23s08.cpp b/esphome/components/mcp23s08/mcp23s08.cpp index 3d944b45d5..1c17b66637 100644 --- a/esphome/components/mcp23s08/mcp23s08.cpp +++ b/esphome/components/mcp23s08/mcp23s08.cpp @@ -6,6 +6,11 @@ namespace mcp23s08 { static const char *const TAG = "mcp23s08"; +// IOCON register bits +static constexpr uint8_t IOCON_SEQOP = 0x20; // Sequential operation mode +static constexpr uint8_t IOCON_HAEN = 0x08; // Hardware address enable +static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin + void MCP23S08::set_device_address(uint8_t device_addr) { if (device_addr != 0) { this->device_opcode_ |= ((device_addr & 0x03) << 1); @@ -15,19 +20,19 @@ void MCP23S08::set_device_address(uint8_t device_addr) { void MCP23S08::setup() { this->spi_setup(); + // Enable HAEN (broadcast to all chips since HAEN isn't active yet) this->enable(); - uint8_t cmd = 0b01000000; - this->transfer_byte(cmd); + this->transfer_byte(0b01000000); this->transfer_byte(mcp23x08_base::MCP23X08_IOCON); - this->transfer_byte(0b00011000); // Enable HAEN pins for addressing + this->transfer_byte(IOCON_SEQOP | IOCON_HAEN); this->disable(); // Read current output register state this->read_reg(mcp23x08_base::MCP23X08_OLAT, &this->olat_); if (this->open_drain_ints_) { - // enable open-drain interrupt pins, 3.3V-safe - this->write_reg(mcp23x08_base::MCP23X08_IOCON, 0x04); + // enable open-drain interrupt pins, 3.3V-safe (addressed, only this chip) + this->write_reg(mcp23x08_base::MCP23X08_IOCON, IOCON_SEQOP | IOCON_HAEN | IOCON_ODR); } } diff --git a/esphome/components/mcp23s17/mcp23s17.cpp b/esphome/components/mcp23s17/mcp23s17.cpp index 1624eda9e4..c6abd7ad59 100644 --- a/esphome/components/mcp23s17/mcp23s17.cpp +++ b/esphome/components/mcp23s17/mcp23s17.cpp @@ -6,6 +6,11 @@ namespace mcp23s17 { static const char *const TAG = "mcp23s17"; +// IOCON register bits +static constexpr uint8_t IOCON_SEQOP = 0x20; // Sequential operation mode +static constexpr uint8_t IOCON_HAEN = 0x08; // Hardware address enable +static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin + void MCP23S17::set_device_address(uint8_t device_addr) { if (device_addr != 0) { this->device_opcode_ |= ((device_addr & 0b111) << 1); @@ -15,18 +20,17 @@ void MCP23S17::set_device_address(uint8_t device_addr) { void MCP23S17::setup() { this->spi_setup(); + // Enable HAEN (broadcast to addresses 0 and 4 since HAEN isn't active yet) this->enable(); - uint8_t cmd = 0b01000000; - this->transfer_byte(cmd); + this->transfer_byte(0b01000000); this->transfer_byte(mcp23x17_base::MCP23X17_IOCONA); - this->transfer_byte(0b00011000); // Enable HAEN pins for addressing + this->transfer_byte(IOCON_SEQOP | IOCON_HAEN); this->disable(); this->enable(); - cmd = 0b01001000; - this->transfer_byte(cmd); + this->transfer_byte(0b01001000); this->transfer_byte(mcp23x17_base::MCP23X17_IOCONA); - this->transfer_byte(0b00011000); // Enable HAEN pins for addressing + this->transfer_byte(IOCON_SEQOP | IOCON_HAEN); this->disable(); // Read current output register state @@ -34,9 +38,9 @@ void MCP23S17::setup() { this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_); if (this->open_drain_ints_) { - // enable open-drain interrupt pins, 3.3V-safe - this->write_reg(mcp23x17_base::MCP23X17_IOCONA, 0x04); - this->write_reg(mcp23x17_base::MCP23X17_IOCONB, 0x04); + // enable open-drain interrupt pins, 3.3V-safe (addressed, only this chip) + this->write_reg(mcp23x17_base::MCP23X17_IOCONA, IOCON_SEQOP | IOCON_HAEN | IOCON_ODR); + this->write_reg(mcp23x17_base::MCP23X17_IOCONB, IOCON_SEQOP | IOCON_HAEN | IOCON_ODR); } } diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index 1cbc84191f..51b94eb767 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -126,21 +126,21 @@ void MMC5603Component::update() { int32_t raw_x = 0; raw_x |= buffer[0] << 12; raw_x |= buffer[1] << 4; - raw_x |= buffer[2] << 0; + raw_x |= buffer[2] & 0x0F; const float x = 0.00625 * (raw_x - 524288); int32_t raw_y = 0; raw_y |= buffer[3] << 12; raw_y |= buffer[4] << 4; - raw_y |= buffer[5] << 0; + raw_y |= buffer[5] & 0x0F; const float y = 0.00625 * (raw_y - 524288); int32_t raw_z = 0; raw_z |= buffer[6] << 12; raw_z |= buffer[7] << 4; - raw_z |= buffer[8] << 0; + raw_z |= buffer[8] & 0x0F; const float z = 0.00625 * (raw_z - 524288); diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index dfe1277297..1cdae76eaf 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -309,8 +309,8 @@ void SX1509Component::set_debounce_keypad_(uint8_t time, uint8_t num_rows, uint8 set_debounce_time_(time); for (uint16_t i = 0; i < num_rows; i++) set_debounce_pin_(i); - for (uint16_t i = 0; i < (8 + num_cols); i++) - set_debounce_pin_(i); + for (uint16_t i = 0; i < num_cols; i++) + set_debounce_pin_(i + 8); } } // namespace sx1509 From 0a607b9c93c0a1c8504a73de09b564c723a258b2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:36:16 -0400 Subject: [PATCH 047/160] [esp32_ble_server] Fix wrong union member in STOP_EVT handler (#15239) --- esphome/components/esp32_ble_server/ble_service.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_server/ble_service.cpp b/esphome/components/esp32_ble_server/ble_service.cpp index 96fedf2346..8956c87b3e 100644 --- a/esphome/components/esp32_ble_server/ble_service.cpp +++ b/esphome/components/esp32_ble_server/ble_service.cpp @@ -159,7 +159,7 @@ void BLEService::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t g break; } case ESP_GATTS_STOP_EVT: { - if (param->start.service_handle == this->handle_) { + if (param->stop.service_handle == this->handle_) { this->state_ = STOPPED; } break; From 4b9467cd0cd03bf33aa24ec8be48bc9395976a6a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:37:33 -0400 Subject: [PATCH 048/160] [esp32_ble_client] Fix wrong union member in OPEN_EVT handler (#15236) --- esphome/components/esp32_ble_client/ble_client_base.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 9d6e079d92..7f0f2c624d 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -350,7 +350,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // For V3_WITHOUT_CACHE, we already set fast params before connecting // No need to update them again here this->log_event_("Searching for services"); - esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); + esp_ble_gattc_search_service(esp_gattc_if, param->open.conn_id, nullptr); break; } case ESP_GATTC_CONNECT_EVT: { From 53bd57f3c2557d75fade7864b7371a2de27cabc5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:37:54 -0400 Subject: [PATCH 049/160] [pid] Fix inverted debug log conditions and broken smoothing formula (#15240) --- esphome/components/pid/pid_autotuner.cpp | 4 ++-- esphome/components/pid/pid_simulator.h | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/pid/pid_autotuner.cpp b/esphome/components/pid/pid_autotuner.cpp index e1ddd1d7c6..3b971e6559 100644 --- a/esphome/components/pid/pid_autotuner.cpp +++ b/esphome/components/pid/pid_autotuner.cpp @@ -101,10 +101,10 @@ PIDAutotuner::PIDAutotuneResult PIDAutotuner::update(float setpoint, float proce if (!zc_symmetrical || !amplitude_convergent) { // The frequency/amplitude is not fully accurate yet, try to wait // until the fault clears, or terminate after a while anyway - if (zc_symmetrical) { + if (!zc_symmetrical) { ESP_LOGVV(TAG, "%s: ZC is not symmetrical", this->id_.c_str()); } - if (amplitude_convergent) { + if (!amplitude_convergent) { ESP_LOGVV(TAG, "%s: Amplitude is not convergent", this->id_.c_str()); } uint32_t phase = this->relay_function_.phase_count; diff --git a/esphome/components/pid/pid_simulator.h b/esphome/components/pid/pid_simulator.h index 30222f2f7a..629784cea5 100644 --- a/esphome/components/pid/pid_simulator.h +++ b/esphome/components/pid/pid_simulator.h @@ -59,7 +59,7 @@ class PIDSimulator : public PollingComponent, public output::FloatOutput { delayed_temps.erase(delayed_temps.begin()); float prev_temp = this->delayed_temps[0]; float alpha = 0.1f; - float ret = (1 - alpha) * prev_temp + alpha * prev_temp; + float ret = (1 - alpha) * prev_temp + alpha * temperature; return ret; } From 951ad91cb259262675dbfbf7f71d1d6fb27d596a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:39:30 -0400 Subject: [PATCH 050/160] [atm90e32] Fix phase angle precision loss and remove unused member (#15238) --- esphome/components/atm90e32/atm90e32.cpp | 4 ++-- esphome/components/atm90e32/atm90e32.h | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index ee7fe5ce75..db29702c54 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -550,8 +550,8 @@ float ATM90E32Component::get_phase_harmonic_active_power_(uint8_t phase) { } float ATM90E32Component::get_phase_angle_(uint8_t phase) { - uint16_t val = this->read16_(ATM90E32_REGISTER_PANGLE + phase) / 10.0; - return (val > 180) ? (float) (val - 360.0f) : (float) val; + float val = this->read16_(ATM90E32_REGISTER_PANGLE + phase) / 10.0f; + return (val > 180.0f) ? val - 360.0f : val; } float ATM90E32Component::get_phase_peak_current_(uint8_t phase) { diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index 2524616470..c44a11e3ed 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -134,7 +134,6 @@ class ATM90E32Component : public PollingComponent, void set_freq_status_text_sensor(text_sensor::TextSensor *sensor) { this->freq_status_text_sensor_ = sensor; } #endif uint16_t calculate_voltage_threshold(int line_freq, uint16_t ugain, float multiplier); - int32_t last_periodic_millis = millis(); protected: #ifdef USE_NUMBER From 05c15f4241d20c78d3f8eb40a19d3046723aeaf7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:44:40 -0400 Subject: [PATCH 051/160] [remote_base] Fix gobox uint64_t format specifier (#15237) --- esphome/components/remote_base/gobox_protocol.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/remote_base/gobox_protocol.cpp b/esphome/components/remote_base/gobox_protocol.cpp index 4f6de5e59e..0e1617659d 100644 --- a/esphome/components/remote_base/gobox_protocol.cpp +++ b/esphome/components/remote_base/gobox_protocol.cpp @@ -1,5 +1,6 @@ #include "gobox_protocol.h" #include "esphome/core/log.h" +#include namespace esphome { namespace remote_base { @@ -25,7 +26,7 @@ void GoboxProtocol::encode(RemoteTransmitData *dst, const GoboxData &data) { dst->set_carrier_frequency(38000); dst->reserve((HEADER_SIZE + CODE_SIZE + 1) * 2); uint64_t code = (HEADER << CODE_SIZE) | (data.code & ((1UL << CODE_SIZE) - 1)); - ESP_LOGI(TAG, "Send Gobox: code=0x%Lx", code); + ESP_LOGI(TAG, "Send Gobox: code=0x%016" PRIx64, code); for (int16_t i = (HEADER_SIZE + CODE_SIZE - 1); i >= 0; i--) { if (code & ((uint64_t) 1 << i)) { dst->item(BIT_MARK_US, BIT_ONE_SPACE_US); From f0db0c105424e31022e9521b44eb577cfa18d2d5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:48:08 -0400 Subject: [PATCH 052/160] [esp32] Add ESP-IDF 5.5.4 and 6.0.0 version mappings (#15241) --- esphome/components/esp32/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 91eb913e3d..0ce1117262 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -690,9 +690,15 @@ ARDUINO_IDF_VERSION_LOOKUP = { ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { "recommended": cv.Version(5, 5, 3, "1"), "latest": cv.Version(5, 5, 3, "1"), - "dev": cv.Version(5, 5, 3, "1"), + "dev": cv.Version(5, 5, 4), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { + cv.Version( + 6, 0, 0 + ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version( + 5, 5, 4 + ): "https://github.com/pioarduino/platform-espressif32.git#develop", cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), cv.Version(5, 5, 2): cv.Version(55, 3, 37), From 7532e1f957499ca880c71c0e8c1f693c585b4cf3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:58:41 -0400 Subject: [PATCH 053/160] [multiple] Fix uninitialized members and error constant types (#15235) --- esphome/components/max44009/max44009.cpp | 18 +++++++++--------- esphome/components/max44009/max44009.h | 4 ++-- .../modbus_controller/output/modbus_output.h | 4 ++-- esphome/components/tuya/climate/tuya_climate.h | 10 +++++----- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/esphome/components/max44009/max44009.cpp b/esphome/components/max44009/max44009.cpp index 8b8e38c1ea..cbce053519 100644 --- a/esphome/components/max44009/max44009.cpp +++ b/esphome/components/max44009/max44009.cpp @@ -8,17 +8,17 @@ namespace max44009 { static const char *const TAG = "max44009.sensor"; // REGISTERS -static const uint8_t MAX44009_REGISTER_CONFIGURATION = 0x02; -static const uint8_t MAX44009_LUX_READING_HIGH = 0x03; -static const uint8_t MAX44009_LUX_READING_LOW = 0x04; +static constexpr uint8_t MAX44009_REGISTER_CONFIGURATION = 0x02; +static constexpr uint8_t MAX44009_LUX_READING_HIGH = 0x03; +static constexpr uint8_t MAX44009_LUX_READING_LOW = 0x04; // CONFIGURATION MASKS -static const uint8_t MAX44009_CFG_CONTINUOUS = 0x80; +static constexpr uint8_t MAX44009_CFG_CONTINUOUS = 0x80; // ERROR CODES -static const uint8_t MAX44009_OK = 0; -static const uint8_t MAX44009_ERROR_WIRE_REQUEST = -10; -static const uint8_t MAX44009_ERROR_OVERFLOW = -20; -static const uint8_t MAX44009_ERROR_HIGH_BYTE = -30; -static const uint8_t MAX44009_ERROR_LOW_BYTE = -31; +static constexpr int8_t MAX44009_OK = 0; +static constexpr int8_t MAX44009_ERROR_WIRE_REQUEST = -10; +static constexpr int8_t MAX44009_ERROR_OVERFLOW = -20; +static constexpr int8_t MAX44009_ERROR_HIGH_BYTE = -30; +static constexpr int8_t MAX44009_ERROR_LOW_BYTE = -31; void MAX44009Sensor::setup() { bool state_ok = false; diff --git a/esphome/components/max44009/max44009.h b/esphome/components/max44009/max44009.h index 59eea66ed9..d0ffd7bc70 100644 --- a/esphome/components/max44009/max44009.h +++ b/esphome/components/max44009/max44009.h @@ -28,8 +28,8 @@ class MAX44009Sensor : public sensor::Sensor, public PollingComponent, public i2 uint8_t read_(uint8_t reg); void write_(uint8_t reg, uint8_t value); - int error_; - MAX44009Mode mode_; + int8_t error_{0}; + MAX44009Mode mode_{MAX44009_MODE_AUTO}; }; } // namespace max44009 diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index 0fb4bb89ea..3f3cadfe2f 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -15,7 +15,7 @@ class ModbusFloatOutput : public output::FloatOutput, public Component, public S this->register_type = ModbusRegisterType::HOLDING; this->start_address = start_address; this->offset = offset; - this->bitmask = bitmask; + this->bitmask = 0xFFFFFFFF; this->register_count = register_count; this->sensor_value_type = value_type; this->skip_updates = 0; @@ -47,7 +47,7 @@ class ModbusBinaryOutput : public output::BinaryOutput, public Component, public ModbusBinaryOutput(uint16_t start_address, uint8_t offset) { this->register_type = ModbusRegisterType::COIL; this->start_address = start_address; - this->bitmask = bitmask; + this->bitmask = 0xFFFFFFFF; this->sensor_value_type = SensorValueType::BIT; this->skip_updates = 0; this->register_count = 1; diff --git a/esphome/components/tuya/climate/tuya_climate.h b/esphome/components/tuya/climate/tuya_climate.h index 31bef57639..09f3fd30c3 100644 --- a/esphome/components/tuya/climate/tuya_climate.h +++ b/esphome/components/tuya/climate/tuya_climate.h @@ -105,8 +105,8 @@ class TuyaClimate : public climate::Climate, public Component { optional sleep_id_{}; optional eco_temperature_{}; TuyaDatapointType eco_type_{}; - uint8_t active_state_; - uint8_t fan_state_; + uint8_t active_state_{0}; + uint8_t fan_state_{0}; optional swing_vertical_id_{}; optional swing_horizontal_id_{}; optional fan_speed_id_{}; @@ -119,9 +119,9 @@ class TuyaClimate : public climate::Climate, public Component { bool swing_horizontal_{false}; bool heating_state_{false}; bool cooling_state_{false}; - float manual_temperature_; - bool eco_; - bool sleep_; + float manual_temperature_{NAN}; + bool eco_{false}; + bool sleep_{false}; bool reports_fahrenheit_{false}; }; From 3016cd363617d080e7eb6ed6532e5668cb07bdbc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:29:08 -1000 Subject: [PATCH 054/160] Bump github/codeql-action from 4.34.1 to 4.35.1 (#15245) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6baab70b42..67f4690ac9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1 + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1 + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: category: "/language:${{matrix.language}}" From a2dee21e8e8f43c9ba68ab5a7b99908d7cd22eaf Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:24:19 +0100 Subject: [PATCH 055/160] [nextion] Replace `std::deque` queues with `std::list` (#15211) --- esphome/components/nextion/nextion.cpp | 14 ++++++-------- esphome/components/nextion/nextion.h | 12 ++++++------ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index fa1582c209..964dbfb660 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -841,10 +841,10 @@ void Nextion::process_nextion_commands_() { if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() && ms - this->nextion_queue_.front()->queue_time > this->max_q_age_ms_) { - for (size_t i = 0; i < this->nextion_queue_.size(); i++) { - NextionComponentBase *component = this->nextion_queue_[i]->component; - if (ms - this->nextion_queue_[i]->queue_time > this->max_q_age_ms_) { - if (this->nextion_queue_[i]->queue_time == 0) { + for (auto it = this->nextion_queue_.begin(); it != this->nextion_queue_.end();) { + NextionComponentBase *component = (*it)->component; + if (ms - (*it)->queue_time > this->max_q_age_ms_) { + if ((*it)->queue_time == 0) { ESP_LOGD(TAG, "Remove old queue '%s':'%s' (t=0)", component->get_queue_type_string().c_str(), component->get_variable_name().c_str()); } @@ -863,10 +863,8 @@ void Nextion::process_nextion_commands_() { delete component; // NOLINT(cppcoreguidelines-owning-memory) } - delete this->nextion_queue_[i]; // NOLINT(cppcoreguidelines-owning-memory) - - this->nextion_queue_.erase(this->nextion_queue_.begin() + i); - i--; + delete *it; // NOLINT(cppcoreguidelines-owning-memory) + it = this->nextion_queue_.erase(it); } else { break; diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index 217d2e605d..b5aaecd667 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1,16 +1,16 @@ #pragma once -#include +#include #include +#include "esphome/components/display/display.h" +#include "esphome/components/display/display_color_utils.h" +#include "esphome/components/uart/uart.h" #include "esphome/core/defines.h" #include "esphome/core/time.h" -#include "esphome/components/uart/uart.h" #include "nextion_base.h" #include "nextion_component.h" -#include "esphome/components/display/display.h" -#include "esphome/components/display/display_color_utils.h" #ifdef USE_NEXTION_TFT_UPLOAD #ifdef USE_ESP32 @@ -1391,8 +1391,8 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe void process_pending_in_queue_(); #endif // USE_NEXTION_COMMAND_SPACING - std::deque nextion_queue_; - std::deque waveform_queue_; + std::list nextion_queue_; + std::list waveform_queue_; uint16_t recv_ret_string_(std::string &response, uint32_t timeout, bool recv_flag); void all_components_send_state_(bool force_update = false); uint32_t comok_sent_ = 0; From d245b9f123e37618e51f027412f9cc860309478a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:24:03 -0400 Subject: [PATCH 056/160] [sm2135] Fix copy-paste error in setup pin mode (#15248) --- esphome/components/sm2135/sm2135.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sm2135/sm2135.cpp b/esphome/components/sm2135/sm2135.cpp index 1293c3f321..c3d10e70c2 100644 --- a/esphome/components/sm2135/sm2135.cpp +++ b/esphome/components/sm2135/sm2135.cpp @@ -25,7 +25,7 @@ void SM2135::setup() { this->data_pin_->pin_mode(gpio::FLAG_OUTPUT); this->clock_pin_->setup(); this->clock_pin_->digital_write(false); - this->data_pin_->pin_mode(gpio::FLAG_OUTPUT); + this->clock_pin_->pin_mode(gpio::FLAG_OUTPUT); this->data_pin_->pin_mode(gpio::FLAG_PULLUP); this->clock_pin_->pin_mode(gpio::FLAG_PULLUP); From 24b8a95340d79ad00157c261e0e3c9f92998439e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:24:15 -0400 Subject: [PATCH 057/160] [pid] Remove unused PIDSimulator class (#15247) --- esphome/components/pid/pid_autotuner.h | 1 - esphome/components/pid/pid_simulator.h | 77 -------------------------- 2 files changed, 78 deletions(-) delete mode 100644 esphome/components/pid/pid_simulator.h diff --git a/esphome/components/pid/pid_autotuner.h b/esphome/components/pid/pid_autotuner.h index 98dc02bcc4..1db9ca7138 100644 --- a/esphome/components/pid/pid_autotuner.h +++ b/esphome/components/pid/pid_autotuner.h @@ -3,7 +3,6 @@ #include "esphome/core/component.h" #include "esphome/core/optional.h" #include "pid_controller.h" -#include "pid_simulator.h" #include diff --git a/esphome/components/pid/pid_simulator.h b/esphome/components/pid/pid_simulator.h deleted file mode 100644 index 629784cea5..0000000000 --- a/esphome/components/pid/pid_simulator.h +++ /dev/null @@ -1,77 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/helpers.h" -#include "esphome/components/sensor/sensor.h" -#include "esphome/components/output/float_output.h" - -#include - -namespace esphome { -namespace pid { - -class PIDSimulator : public PollingComponent, public output::FloatOutput { - public: - PIDSimulator() : PollingComponent(1000) {} - - float surface = 1; /// surface area in m² - float mass = 3; /// mass of simulated object in kg - float temperature = 21; /// current temperature of object in °C - float efficiency = 0.98; /// heating efficiency, 1 is 100% efficient - float thermal_conductivity = 15; /// thermal conductivity of surface are in W/(m*K), here: steel - float specific_heat_capacity = 4.182; /// specific heat capacity of mass in kJ/(kg*K), here: water - float heat_power = 500; /// Heating power in W - float ambient_temperature = 20; /// Ambient temperature in °C - float update_interval = 1; /// The simulated updated interval in seconds - std::vector delayed_temps; /// storage of past temperatures for delaying temperature reading - size_t delay_cycles = 15; /// how many update cycles to delay the output - float output_value = 0.0; /// Current output value of heating element - sensor::Sensor *sensor = new sensor::Sensor(); - - float delta_t(float power) { - // P = Q / t - // Q = c * m * 𝚫t - // 𝚫t = (P*t) / (c*m) - float c = this->specific_heat_capacity; - float t = this->update_interval; - float p = power / 1000; // in kW - float m = this->mass; - return (p * t) / (c * m); - } - - float update_temp() { - float value = clamp(output_value, 0.0f, 1.0f); - - // Heat - float power = value * heat_power * efficiency; - temperature += this->delta_t(power); - - // Cool - // Q = k_w * A * (T_mass - T_ambient) - // P = Q / t - float dt = temperature - ambient_temperature; - float cool_power = (thermal_conductivity * surface * dt) / update_interval; - temperature -= this->delta_t(cool_power); - - // Delay temperature readings - delayed_temps.push_back(temperature); - if (delayed_temps.size() > delay_cycles) - delayed_temps.erase(delayed_temps.begin()); - float prev_temp = this->delayed_temps[0]; - float alpha = 0.1f; - float ret = (1 - alpha) * prev_temp + alpha * temperature; - return ret; - } - - void setup() override { sensor->publish_state(this->temperature); } - void update() override { - float new_temp = this->update_temp(); - sensor->publish_state(new_temp); - } - - protected: - void write_state(float state) override { this->output_value = state; } -}; - -} // namespace pid -} // namespace esphome From 68d9f657adf9d2a65621486627596b5f6275a317 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:32:37 -0400 Subject: [PATCH 058/160] [bl0940] Fix energy reference default using wrong constant in legacy mode (#15249) --- esphome/components/bl0940/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index d2e0ea435d..f36250ecdf 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -124,7 +124,7 @@ def set_reference_values(config): config.setdefault(CONF_VOLTAGE_REFERENCE, DEFAULT_BL0940_LEGACY_UREF) config.setdefault(CONF_CURRENT_REFERENCE, DEFAULT_BL0940_LEGACY_IREF) config.setdefault(CONF_POWER_REFERENCE, DEFAULT_BL0940_LEGACY_PREF) - config.setdefault(CONF_ENERGY_REFERENCE, DEFAULT_BL0940_LEGACY_PREF) + config.setdefault(CONF_ENERGY_REFERENCE, DEFAULT_BL0940_LEGACY_EREF) else: vref = config.get(CONF_VOLTAGE_REFERENCE, DEFAULT_BL0940_VREF) r_one = config.get(CONF_RESISTOR_ONE, DEFAULT_BL0940_R1) From 76d75850a3bd100fb056d5a82c8792abb3f23154 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:35:12 -0400 Subject: [PATCH 059/160] [sgp4x] Remove dead voc_baseline config option (#15250) --- esphome/components/sgp4x/sensor.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 8d52ffb4f2..1e58a0f26a 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_STORE_BASELINE, CONF_TEMPERATURE_SOURCE, CONF_VOC, - CONF_VOC_BASELINE, DEVICE_CLASS_AQI, ICON_RADIATOR, STATE_CLASS_MEASUREMENT, @@ -83,7 +82,6 @@ CONFIG_SCHEMA = cv.All( state_class=STATE_CLASS_MEASUREMENT, ).extend(NOX_SENSOR), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, - cv.Optional(CONF_VOC_BASELINE): cv.hex_uint16_t, cv.Optional(CONF_COMPENSATION): cv.Schema( { cv.Required(CONF_HUMIDITY_SOURCE): cv.use_id(sensor.Sensor), @@ -112,9 +110,6 @@ async def to_code(config): cg.add(var.set_store_baseline(config[CONF_STORE_BASELINE])) - if CONF_VOC_BASELINE in config: - cg.add(var.set_voc_baseline(CONF_VOC_BASELINE)) - if CONF_VOC in config: sens = await sensor.new_sensor(config[CONF_VOC]) cg.add(var.set_voc_sensor(sens)) From f6c63c62e43b88b8933a3f02f866c7e0936dd290 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 27 Mar 2026 17:59:26 -0500 Subject: [PATCH 060/160] [tmp117] Code clean-up (#15260) --- esphome/components/tmp117/tmp117.cpp | 17 +++++++---------- esphome/components/tmp117/tmp117.h | 6 ++---- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/esphome/components/tmp117/tmp117.cpp b/esphome/components/tmp117/tmp117.cpp index f8f52266e0..b3e900f5b6 100644 --- a/esphome/components/tmp117/tmp117.cpp +++ b/esphome/components/tmp117/tmp117.cpp @@ -4,8 +4,7 @@ #include "tmp117.h" #include "esphome/core/log.h" -namespace esphome { -namespace tmp117 { +namespace esphome::tmp117 { static const char *const TAG = "tmp117"; @@ -18,11 +17,10 @@ void TMP117Component::update() { if ((uint16_t) data != 0x8000) { float temperature = data * 0.0078125f; - ESP_LOGD(TAG, "Got temperature=%.2f°C", temperature); this->publish_state(temperature); this->status_clear_warning(); } else { - ESP_LOGD(TAG, "TMP117 not ready"); + ESP_LOGD(TAG, "Not ready"); } } void TMP117Component::setup() { @@ -38,7 +36,7 @@ void TMP117Component::setup() { } } void TMP117Component::dump_config() { - ESP_LOGD(TAG, "TMP117:"); + ESP_LOGCONFIG(TAG, "TMP117:"); LOG_I2C_DEVICE(this); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -48,7 +46,7 @@ void TMP117Component::dump_config() { bool TMP117Component::read_data_(int16_t *data) { if (!this->read_byte_16(0, (uint16_t *) data)) { - ESP_LOGW(TAG, "Updating TMP117 failed!"); + ESP_LOGW(TAG, "Updating failed"); return false; } return true; @@ -56,7 +54,7 @@ bool TMP117Component::read_data_(int16_t *data) { bool TMP117Component::read_config_(uint16_t *config) { if (!this->read_byte_16(1, (uint16_t *) config)) { - ESP_LOGW(TAG, "Reading TMP117 config failed!"); + ESP_LOGW(TAG, "Reading config failed"); return false; } return true; @@ -64,11 +62,10 @@ bool TMP117Component::read_config_(uint16_t *config) { bool TMP117Component::write_config_(uint16_t config) { if (!this->write_byte_16(1, config)) { - ESP_LOGE(TAG, "Writing TMP117 config failed!"); + ESP_LOGE(TAG, "Writing config failed"); return false; } return true; } -} // namespace tmp117 -} // namespace esphome +} // namespace esphome::tmp117 diff --git a/esphome/components/tmp117/tmp117.h b/esphome/components/tmp117/tmp117.h index f501ee270c..a8fe7ac7ce 100644 --- a/esphome/components/tmp117/tmp117.h +++ b/esphome/components/tmp117/tmp117.h @@ -4,8 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -namespace esphome { -namespace tmp117 { +namespace esphome::tmp117 { class TMP117Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: @@ -22,5 +21,4 @@ class TMP117Component : public PollingComponent, public i2c::I2CDevice, public s uint16_t config_; }; -} // namespace tmp117 -} // namespace esphome +} // namespace esphome::tmp117 From a99f051e19759c70e2007deb4b873327c9127699 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:49:00 +0100 Subject: [PATCH 061/160] [nextion] Replace queue name string literals with short Nextion-native identifiers (#15215) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/nextion/nextion.cpp | 13 +- .../components/nextion/nextion_commands.cpp | 115 ++++++++---------- 2 files changed, 62 insertions(+), 66 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 964dbfb660..97d9b36e4c 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -241,7 +241,7 @@ bool Nextion::send_command(const char *command) { return false; if (this->send_command_(command)) { - this->add_no_result_to_queue_("send_command"); + this->add_no_result_to_queue_("command"); return true; } return false; @@ -262,7 +262,7 @@ bool Nextion::send_command_printf(const char *format, ...) { } if (this->send_command_(buffer)) { - this->add_no_result_to_queue_("send_command_printf"); + this->add_no_result_to_queue_("command_printf"); return true; } return false; @@ -853,8 +853,13 @@ void Nextion::process_nextion_commands_() { this->is_sleeping_ = false; } - ESP_LOGD(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string().c_str(), - component->get_variable_name().c_str()); + if ((*it)->pending_command.empty()) { + ESP_LOGD(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string().c_str(), + component->get_variable_name().c_str()); + } else { + ESP_LOGD(TAG, "Remove old queue '%s':'%s' cmd:'%s'", component->get_queue_type_string().c_str(), + component->get_variable_name().c_str(), (*it)->pending_command.c_str()); + } if (component->get_queue_type() == NextionQueueType::NO_RESULT) { if (component->get_variable_name() == "sleep_wake") { diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index 4ddbfbee6a..6718646efa 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -12,7 +12,7 @@ void Nextion::soft_reset() { this->send_command_("rest"); } void Nextion::set_wake_up_page(uint8_t wake_up_page) { this->wake_up_page_ = wake_up_page; - this->add_no_result_to_queue_with_set_internal_("wake_up_page", "wup", wake_up_page, true); + this->add_no_result_to_queue_with_set_internal_("wup", "wup", wake_up_page, true); } void Nextion::set_touch_sleep_timeout(const uint16_t touch_sleep_timeout) { @@ -23,7 +23,7 @@ void Nextion::set_touch_sleep_timeout(const uint16_t touch_sleep_timeout) { this->touch_sleep_timeout_ = touch_sleep_timeout; } - this->add_no_result_to_queue_with_set_internal_("touch_sleep_timeout", "thsp", this->touch_sleep_timeout_, true); + this->add_no_result_to_queue_with_set_internal_("thsp", "thsp", this->touch_sleep_timeout_, true); } void Nextion::sleep(bool sleep) { @@ -58,115 +58,107 @@ bool Nextion::set_protocol_reparse_mode(bool active_mode) { // Set Colors - Background void Nextion::set_component_background_color(const char *component, uint16_t color) { - this->add_no_result_to_queue_with_printf_("set_component_background_color", "%s.bco=%" PRIu16, component, color); + this->add_no_result_to_queue_with_printf_(".bco", "%s.bco=%" PRIu16, component, color); } void Nextion::set_component_background_color(const char *component, const char *color) { - this->add_no_result_to_queue_with_printf_("set_component_background_color", "%s.bco=%s", component, color); + this->add_no_result_to_queue_with_printf_(".bco", "%s.bco=%s", component, color); } void Nextion::set_component_background_color(const char *component, Color color) { - this->add_no_result_to_queue_with_printf_("set_component_background_color", "%s.bco=%d", component, - display::ColorUtil::color_to_565(color)); + this->add_no_result_to_queue_with_printf_(".bco", "%s.bco=%d", component, display::ColorUtil::color_to_565(color)); } // Set Colors - Background (pressed) void Nextion::set_component_pressed_background_color(const char *component, uint16_t color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_background_color", "%s.bco2=%" PRIu16, component, - color); + this->add_no_result_to_queue_with_printf_(".bco2", "%s.bco2=%" PRIu16, component, color); } void Nextion::set_component_pressed_background_color(const char *component, const char *color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_background_color", "%s.bco2=%s", component, color); + this->add_no_result_to_queue_with_printf_(".bco2", "%s.bco2=%s", component, color); } void Nextion::set_component_pressed_background_color(const char *component, Color color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_background_color", "%s.bco2=%d", component, - display::ColorUtil::color_to_565(color)); + this->add_no_result_to_queue_with_printf_(".bco2", "%s.bco2=%d", component, display::ColorUtil::color_to_565(color)); } // Set Colors - Foreground void Nextion::set_component_foreground_color(const char *component, uint16_t color) { - this->add_no_result_to_queue_with_printf_("set_component_foreground_color", "%s.pco=%" PRIu16, component, color); + this->add_no_result_to_queue_with_printf_(".pco", "%s.pco=%" PRIu16, component, color); } void Nextion::set_component_foreground_color(const char *component, const char *color) { - this->add_no_result_to_queue_with_printf_("set_component_foreground_color", "%s.pco=%s", component, color); + this->add_no_result_to_queue_with_printf_(".pco", "%s.pco=%s", component, color); } void Nextion::set_component_foreground_color(const char *component, Color color) { - this->add_no_result_to_queue_with_printf_("set_component_foreground_color", "%s.pco=%d", component, - display::ColorUtil::color_to_565(color)); + this->add_no_result_to_queue_with_printf_(".pco", "%s.pco=%d", component, display::ColorUtil::color_to_565(color)); } // Set Colors - Foreground (pressed) void Nextion::set_component_pressed_foreground_color(const char *component, uint16_t color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_foreground_color", "%s.pco2=%" PRIu16, component, - color); + this->add_no_result_to_queue_with_printf_(".pco2", "%s.pco2=%" PRIu16, component, color); } void Nextion::set_component_pressed_foreground_color(const char *component, const char *color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_foreground_color", "%s.pco2=%s", component, color); + this->add_no_result_to_queue_with_printf_(".pco2", "%s.pco2=%s", component, color); } void Nextion::set_component_pressed_foreground_color(const char *component, Color color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_foreground_color", "%s.pco2=%d", component, - display::ColorUtil::color_to_565(color)); + this->add_no_result_to_queue_with_printf_(".pco2", "%s.pco2=%d", component, display::ColorUtil::color_to_565(color)); } // Set Colors - Font void Nextion::set_component_font_color(const char *component, uint16_t color) { - this->add_no_result_to_queue_with_printf_("set_component_font_color", "%s.pco=%" PRIu16, component, color); + this->add_no_result_to_queue_with_printf_(".pco", "%s.pco=%" PRIu16, component, color); } void Nextion::set_component_font_color(const char *component, const char *color) { - this->add_no_result_to_queue_with_printf_("set_component_font_color", "%s.pco=%s", component, color); + this->add_no_result_to_queue_with_printf_(".pco", "%s.pco=%s", component, color); } void Nextion::set_component_font_color(const char *component, Color color) { - this->add_no_result_to_queue_with_printf_("set_component_font_color", "%s.pco=%d", component, - display::ColorUtil::color_to_565(color)); + this->add_no_result_to_queue_with_printf_(".pco", "%s.pco=%d", component, display::ColorUtil::color_to_565(color)); } // Set Colors - Font (pressed) void Nextion::set_component_pressed_font_color(const char *component, uint16_t color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_font_color", "%s.pco2=%" PRIu16, component, color); + this->add_no_result_to_queue_with_printf_(".pco2", "%s.pco2=%" PRIu16, component, color); } void Nextion::set_component_pressed_font_color(const char *component, const char *color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_font_color", "%s.pco2=%s", component, color); + this->add_no_result_to_queue_with_printf_(".pco2", "%s.pco2=%s", component, color); } void Nextion::set_component_pressed_font_color(const char *component, Color color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_font_color", "%s.pco2=%d", component, - display::ColorUtil::color_to_565(color)); + this->add_no_result_to_queue_with_printf_(".pco2", "%s.pco2=%d", component, display::ColorUtil::color_to_565(color)); } // Set picture void Nextion::set_component_pic(const char *component, uint16_t pic_id) { - this->add_no_result_to_queue_with_printf_("set_component_pic", "%s.pic=%" PRIu16, component, pic_id); + this->add_no_result_to_queue_with_printf_(".pic", "%s.pic=%" PRIu16, component, pic_id); } void Nextion::set_component_picc(const char *component, uint16_t pic_id) { - this->add_no_result_to_queue_with_printf_("set_component_picc", "%s.picc=%" PRIu16, component, pic_id); + this->add_no_result_to_queue_with_printf_(".picc", "%s.picc=%" PRIu16, component, pic_id); } // Set video void Nextion::set_component_vid(const char *component, uint8_t vid_id) { - this->add_no_result_to_queue_with_printf_("set_component_vid", "%s.vid=%" PRIu8, component, vid_id); + this->add_no_result_to_queue_with_printf_(".vid", "%s.vid=%" PRIu8, component, vid_id); } void Nextion::set_component_drag(const char *component, bool drag) { - this->add_no_result_to_queue_with_printf_("set_component_drag", "%s.drag=%i", component, drag ? 1 : 0); + this->add_no_result_to_queue_with_printf_(".drag", "%s.drag=%i", component, drag ? 1 : 0); } void Nextion::set_component_aph(const char *component, uint8_t aph) { - this->add_no_result_to_queue_with_printf_("set_component_aph", "%s.aph=%" PRIu8, component, aph); + this->add_no_result_to_queue_with_printf_(".aph", "%s.aph=%" PRIu8, component, aph); } void Nextion::set_component_position(const char *component, uint32_t x, uint32_t y) { - this->add_no_result_to_queue_with_printf_("set_component_position_x", "%s.x=%" PRIu32, component, x); - this->add_no_result_to_queue_with_printf_("set_component_position_y", "%s.y=%" PRIu32, component, y); + this->add_no_result_to_queue_with_printf_(".x", "%s.x=%" PRIu32, component, x); + this->add_no_result_to_queue_with_printf_(".y", "%s.y=%" PRIu32, component, y); } void Nextion::set_component_text_printf(const char *component, const char *format, ...) { @@ -180,29 +172,29 @@ void Nextion::set_component_text_printf(const char *component, const char *forma } // General Nextion -void Nextion::goto_page(const char *page) { this->add_no_result_to_queue_with_printf_("goto_page", "page %s", page); } -void Nextion::goto_page(uint8_t page) { this->add_no_result_to_queue_with_printf_("goto_page", "page %i", page); } +void Nextion::goto_page(const char *page) { this->add_no_result_to_queue_with_printf_("page", "page %s", page); } +void Nextion::goto_page(uint8_t page) { this->add_no_result_to_queue_with_printf_("page", "page %i", page); } void Nextion::set_backlight_brightness(float brightness) { if (brightness < 0 || brightness > 1.0) { ESP_LOGD(TAG, "Brightness out of bounds (0-1.0)"); return; } - this->add_no_result_to_queue_with_printf_("backlight_brightness", "dim=%d", static_cast(brightness * 100)); + this->add_no_result_to_queue_with_printf_("dim", "dim=%d", static_cast(brightness * 100)); } void Nextion::set_auto_wake_on_touch(bool auto_wake_on_touch) { this->connection_state_.auto_wake_on_touch_ = auto_wake_on_touch; - this->add_no_result_to_queue_with_set("auto_wake_on_touch", "thup", auto_wake_on_touch ? 1 : 0); + this->add_no_result_to_queue_with_set("thup", "thup", auto_wake_on_touch ? 1 : 0); } // General Component void Nextion::set_component_font(const char *component, uint8_t font_id) { - this->add_no_result_to_queue_with_printf_("set_component_font", "%s.font=%" PRIu8, component, font_id); + this->add_no_result_to_queue_with_printf_(".font", "%s.font=%" PRIu8, component, font_id); } void Nextion::set_component_visibility(const char *component, bool show) { - this->add_no_result_to_queue_with_printf_("set_component_visibility", "vis %s,%d", component, show ? 1 : 0); + this->add_no_result_to_queue_with_printf_("vis", "vis %s,%d", component, show ? 1 : 0); } void Nextion::hide_component(const char *component) { this->set_component_visibility(component, false); } @@ -210,56 +202,55 @@ void Nextion::hide_component(const char *component) { this->set_component_visibi void Nextion::show_component(const char *component) { this->set_component_visibility(component, true); } void Nextion::enable_component_touch(const char *component) { - this->add_no_result_to_queue_with_printf_("enable_component_touch", "tsw %s,1", component); + this->add_no_result_to_queue_with_printf_("tsw", "tsw %s,1", component); } void Nextion::disable_component_touch(const char *component) { - this->add_no_result_to_queue_with_printf_("disable_component_touch", "tsw %s,0", component); + this->add_no_result_to_queue_with_printf_("tsw", "tsw %s,0", component); } void Nextion::set_component_text(const char *component, const char *text) { - this->add_no_result_to_queue_with_printf_("set_component_text", "%s.txt=\"%s\"", component, text); + this->add_no_result_to_queue_with_printf_(".txt", "%s.txt=\"%s\"", component, text); } void Nextion::set_component_value(const char *component, int32_t value) { - this->add_no_result_to_queue_with_printf_("set_component_value", "%s.val=%" PRId32, component, value); + this->add_no_result_to_queue_with_printf_(".val", "%s.val=%" PRId32, component, value); } void Nextion::add_waveform_data(uint8_t component_id, uint8_t channel_number, uint8_t value) { - this->add_no_result_to_queue_with_printf_("add_waveform_data", "add %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, - channel_number, value); + this->add_no_result_to_queue_with_printf_("add", "add %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, + value); } void Nextion::open_waveform_channel(uint8_t component_id, uint8_t channel_number, uint8_t value) { - this->add_no_result_to_queue_with_printf_("open_waveform_channel", "addt %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, - channel_number, value); + this->add_no_result_to_queue_with_printf_("addt", "addt %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, + value); } void Nextion::set_component_coordinates(const char *component, uint16_t x, uint16_t y) { - this->add_no_result_to_queue_with_printf_("set_component_coordinates command 1", "%s.xcen=%" PRIu16, component, x); - this->add_no_result_to_queue_with_printf_("set_component_coordinates command 2", "%s.ycen=%" PRIu16, component, y); + this->add_no_result_to_queue_with_printf_(".xcen", "%s.xcen=%" PRIu16, component, x); + this->add_no_result_to_queue_with_printf_(".ycen", "%s.ycen=%" PRIu16, component, y); } // Drawing void Nextion::display_picture(uint16_t picture_id, uint16_t x_start, uint16_t y_start) { - this->add_no_result_to_queue_with_printf_("display_picture", "pic %" PRIu16 ", %" PRIu16 ", %" PRIu16, x_start, - y_start, picture_id); + this->add_no_result_to_queue_with_printf_("pic", "pic %" PRIu16 ", %" PRIu16 ", %" PRIu16, x_start, y_start, + picture_id); } void Nextion::fill_area(uint16_t x1, uint16_t y1, uint16_t width, uint16_t height, uint16_t color) { - this->add_no_result_to_queue_with_printf_( - "fill_area", "fill %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16, x1, y1, width, height, color); -} - -void Nextion::fill_area(uint16_t x1, uint16_t y1, uint16_t width, uint16_t height, const char *color) { - this->add_no_result_to_queue_with_printf_("fill_area", "fill %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%s", x1, + this->add_no_result_to_queue_with_printf_("fill", "fill %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16, x1, y1, width, height, color); } +void Nextion::fill_area(uint16_t x1, uint16_t y1, uint16_t width, uint16_t height, const char *color) { + this->add_no_result_to_queue_with_printf_("fill", "fill %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%s", x1, y1, + width, height, color); +} + void Nextion::fill_area(uint16_t x1, uint16_t y1, uint16_t width, uint16_t height, Color color) { - this->add_no_result_to_queue_with_printf_("fill_area", - "fill %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16, x1, y1, - width, height, display::ColorUtil::color_to_565(color)); + this->add_no_result_to_queue_with_printf_("fill", "fill %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16, x1, + y1, width, height, display::ColorUtil::color_to_565(color)); } void Nextion::line(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color) { From 34410e92b7e1f9ddd15629ecb5903932554e9077 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:55:40 -0400 Subject: [PATCH 062/160] [as5600] Remove dead angle/position sensor code (#15254) --- esphome/components/as5600/sensor/__init__.py | 15 ----------- .../as5600/sensor/as5600_sensor.cpp | 25 +++---------------- .../components/as5600/sensor/as5600_sensor.h | 6 ----- 3 files changed, 4 insertions(+), 42 deletions(-) diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index e84733a484..cf67a3f203 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -2,11 +2,9 @@ import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ANGLE, CONF_GAIN, CONF_ID, CONF_MAGNITUDE, - CONF_POSITION, CONF_STATUS, ENTITY_CATEGORY_DIAGNOSTIC, ICON_MAGNET, @@ -21,7 +19,6 @@ DEPENDENCIES = ["as5600"] AS5600Sensor = as5600_ns.class_("AS5600Sensor", sensor.Sensor, cg.PollingComponent) -CONF_RAW_ANGLE = "raw_angle" CONF_RAW_POSITION = "raw_position" CONF_SLOW_FILTER = "slow_filter" CONF_FAST_FILTER = "fast_filter" @@ -89,18 +86,6 @@ async def to_code(config): if out_of_range_mode_config := config.get(CONF_OUT_OF_RANGE_MODE): cg.add(var.set_out_of_range_mode(out_of_range_mode_config)) - if angle_config := config.get(CONF_ANGLE): - sens = await sensor.new_sensor(angle_config) - cg.add(var.set_angle_sensor(sens)) - - if raw_angle_config := config.get(CONF_RAW_ANGLE): - sens = await sensor.new_sensor(raw_angle_config) - cg.add(var.set_raw_angle_sensor(sens)) - - if position_config := config.get(CONF_POSITION): - sens = await sensor.new_sensor(position_config) - cg.add(var.set_position_sensor(sens)) - if raw_position_config := config.get(CONF_RAW_POSITION): sens = await sensor.new_sensor(raw_position_config) cg.add(var.set_raw_position_sensor(sens)) diff --git a/esphome/components/as5600/sensor/as5600_sensor.cpp b/esphome/components/as5600/sensor/as5600_sensor.cpp index 1c0f4bad2c..4e549d24d5 100644 --- a/esphome/components/as5600/sensor/as5600_sensor.cpp +++ b/esphome/components/as5600/sensor/as5600_sensor.cpp @@ -25,27 +25,10 @@ static const uint8_t REGISTER_MAGNITUDE = 0x1B; // 16 bytes / R void AS5600Sensor::dump_config() { LOG_SENSOR("", "AS5600 Sensor", this); ESP_LOGCONFIG(TAG, " Out of Range Mode: %u", this->out_of_range_mode_); - if (this->angle_sensor_ != nullptr) { - LOG_SENSOR(" ", "Angle Sensor", this->angle_sensor_); - } - if (this->raw_angle_sensor_ != nullptr) { - LOG_SENSOR(" ", "Raw Angle Sensor", this->raw_angle_sensor_); - } - if (this->position_sensor_ != nullptr) { - LOG_SENSOR(" ", "Position Sensor", this->position_sensor_); - } - if (this->raw_position_sensor_ != nullptr) { - LOG_SENSOR(" ", "Raw Position Sensor", this->raw_position_sensor_); - } - if (this->gain_sensor_ != nullptr) { - LOG_SENSOR(" ", "Gain Sensor", this->gain_sensor_); - } - if (this->magnitude_sensor_ != nullptr) { - LOG_SENSOR(" ", "Magnitude Sensor", this->magnitude_sensor_); - } - if (this->status_sensor_ != nullptr) { - LOG_SENSOR(" ", "Status Sensor", this->status_sensor_); - } + LOG_SENSOR(" ", "Raw Position Sensor", this->raw_position_sensor_); + LOG_SENSOR(" ", "Gain Sensor", this->gain_sensor_); + LOG_SENSOR(" ", "Magnitude Sensor", this->magnitude_sensor_); + LOG_SENSOR(" ", "Status Sensor", this->status_sensor_); LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/as5600/sensor/as5600_sensor.h b/esphome/components/as5600/sensor/as5600_sensor.h index d471be49b5..77593f4b12 100644 --- a/esphome/components/as5600/sensor/as5600_sensor.h +++ b/esphome/components/as5600/sensor/as5600_sensor.h @@ -15,9 +15,6 @@ class AS5600Sensor : public PollingComponent, public Parented, void update() override; void dump_config() override; - void set_angle_sensor(sensor::Sensor *angle_sensor) { this->angle_sensor_ = angle_sensor; } - void set_raw_angle_sensor(sensor::Sensor *raw_angle_sensor) { this->raw_angle_sensor_ = raw_angle_sensor; } - void set_position_sensor(sensor::Sensor *position_sensor) { this->position_sensor_ = position_sensor; } void set_raw_position_sensor(sensor::Sensor *raw_position_sensor) { this->raw_position_sensor_ = raw_position_sensor; } @@ -28,9 +25,6 @@ class AS5600Sensor : public PollingComponent, public Parented, OutRangeMode get_out_of_range_mode() { return this->out_of_range_mode_; } protected: - sensor::Sensor *angle_sensor_{nullptr}; - sensor::Sensor *raw_angle_sensor_{nullptr}; - sensor::Sensor *position_sensor_{nullptr}; sensor::Sensor *raw_position_sensor_{nullptr}; sensor::Sensor *gain_sensor_{nullptr}; sensor::Sensor *magnitude_sensor_{nullptr}; From 47774fb644a162c5e38941a1b392ab7374aecb2e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:55:57 -0400 Subject: [PATCH 063/160] [modbus_controller] Fix wrong enum in function_code_to_register (#15253) --- esphome/components/modbus_controller/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index dfc43bf23b..cb0969913a 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -362,7 +362,7 @@ async def register_modbus_device(var, config): def function_code_to_register(function_code): FUNCTION_CODE_TYPE_MAP = { "read_coils": ModbusRegisterType.COIL, - "read_discrete_inputs": ModbusRegisterType.DISCRETE, + "read_discrete_inputs": ModbusRegisterType.DISCRETE_INPUT, "read_holding_registers": ModbusRegisterType.HOLDING, "read_input_registers": ModbusRegisterType.READ, "write_single_coil": ModbusRegisterType.COIL, From b6abfec82e4e51bac2f0a927ca3180b174d70c02 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:22:24 -0400 Subject: [PATCH 064/160] [core] Fix area/device hash collision validation not running (#15259) --- esphome/config.py | 18 ++++++++++++++++++ esphome/core/config.py | 15 ++++++--------- script/ci-custom.py | 12 ++++++++++++ tests/unit_tests/core/test_config.py | 18 ++++++++++++++++++ .../config/area_singular_hash_collision.yaml | 10 ++++++++++ 5 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 tests/unit_tests/fixtures/core/config/area_singular_hash_collision.yaml diff --git a/esphome/config.py b/esphome/config.py index 7a6feea3d3..641b6ec1b4 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -958,6 +958,23 @@ class FinalValidateValidationStep(ConfigValidationStep): fv.full_config.reset(token) +class CoreFinalValidateStep(ConfigValidationStep): + """Run final validation on core esphome config (area/device hash collisions).""" + + # Same priority as component final validate steps + priority = -20.0 + + def run(self, result: Config) -> None: + if result.errors: + return + + token = fv.full_config.set(result) + with result.catch_error([CONF_ESPHOME]): + if CONF_ESPHOME in result: + core_config.validate_ids_and_references(result[CONF_ESPHOME]) + fv.full_config.reset(token) + + class PinUseValidationCheck(ConfigValidationStep): """Check for pin reuse""" @@ -1085,6 +1102,7 @@ def validate_config( for domain, conf in config.items(): result.add_validation_step(LoadValidationStep(domain, conf)) result.add_validation_step(IDPassValidationStep()) + result.add_validation_step(CoreFinalValidateStep()) result.add_validation_step(PinUseValidationCheck()) result.add_validation_step(RemoveReferenceValidationStep()) diff --git a/esphome/core/config.py b/esphome/core/config.py index e02c6ec75f..c47693c783 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -156,22 +156,22 @@ def validate_ids_and_references(config: ConfigType) -> ConfigType: hash_dict[hash_val] = id_obj.id # Collect all areas - all_areas: list[dict[str, str | core.ID]] = [] + all_areas: list[tuple[dict[str, str | core.ID], str]] = [] if CONF_AREA in config: - all_areas.append(config[CONF_AREA]) - all_areas.extend(config[CONF_AREAS]) + all_areas.append((config[CONF_AREA], CONF_AREA)) + all_areas.extend((area, CONF_AREAS) for area in config.get(CONF_AREAS, [])) # Validate area hash collisions and collect IDs area_hashes: dict[int, str] = {} area_ids: set[str] = set() - for area in all_areas: + for area, key in all_areas: area_id: core.ID = area[CONF_ID] - check_hash_collision(area_id, area_hashes, "Area", [CONF_AREAS, area_id.id]) + check_hash_collision(area_id, area_hashes, "Area", [key, area_id.id]) area_ids.add(area_id.id) # Validate device hash collisions and area references device_hashes: dict[int, str] = {} - for device in config[CONF_DEVICES]: + for device in config.get(CONF_DEVICES, []): device_id: core.ID = device[CONF_ID] check_hash_collision( device_id, device_hashes, "Device", [CONF_DEVICES, device_id.id] @@ -329,9 +329,6 @@ CONFIG_SCHEMA = cv.All( ) -FINAL_VALIDATE_SCHEMA = cv.All(validate_ids_and_references) - - PRELOAD_CONFIG_SCHEMA = cv.Schema( { cv.Required(CONF_NAME): cv.valid_name, diff --git a/script/ci-custom.py b/script/ci-custom.py index 7d0680a491..ad39f92005 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1006,6 +1006,18 @@ def lint_log_in_header(fname, line, col, content): ) +@lint_content_find_check( + "FINAL_VALIDATE_SCHEMA", + include=["esphome/core/*.py"], + exclude=["esphome/core/entity_helpers.py"], +) +def lint_final_validate_in_core(fname, line, col, content): + return ( + "FINAL_VALIDATE_SCHEMA in esphome/core/ is not picked up by the component loader. " + "Use CoreFinalValidateStep in esphome/config.py instead." + ) + + def main(): colorama.init() diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 474d31a90a..6fa8f7ed43 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -248,6 +248,24 @@ def test_area_id_hash_collision( ) +def test_area_singular_hash_collision( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that area hash collisions between singular area: and areas: list are detected.""" + result = load_config_from_fixture( + yaml_file, "area_singular_hash_collision.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert ( + "Area ID 'd6ka' with hash 3082558663 collides with existing area ID 'test_2258'" + in captured.out + ) + # Error path should point to 'areas' (where the colliding entry is), not 'area' + assert "areas" in captured.out + + def test_device_duplicate_id( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/unit_tests/fixtures/core/config/area_singular_hash_collision.yaml b/tests/unit_tests/fixtures/core/config/area_singular_hash_collision.yaml new file mode 100644 index 0000000000..6e137f5f6e --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/area_singular_hash_collision.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + area: + id: test_2258 + name: "Area 1" + areas: + - id: d6ka + name: "Area 2" + +host: From 7a7c33fdb16f2b32d95d194ed5130471e6bb99e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 15:38:06 -1000 Subject: [PATCH 065/160] [esp32_ble_server] Fix set_value action with static data lists (#15285) --- .../components/esp32_ble_server/ble_server_automations.h | 2 ++ tests/components/esp32_ble_server/common.yaml | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index fe18600280..0bbfdffd5b 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -70,6 +70,7 @@ template class BLECharacteristicSetValueAction : public Action, buffer) + void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } void play(const Ts &...x) override { // If the listener is already set, do nothing @@ -115,6 +116,7 @@ template class BLEDescriptorSetValueAction : public Action, buffer) + void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } void play(const Ts &...x) override { this->parent_->set_value(this->buffer_.value(x...)); } diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index 7fe0b2eb5f..4e34049038 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -69,3 +69,11 @@ esp32_ble_server: - ble_server.descriptor.set_value: id: test_change_descriptor value: !lambda return bytebuffer::ByteBuffer::wrap({0x03, 0x04, 0x05}).get_data(); + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: + data: [0xfc, 0xef, 0xfe, 0x86] + - ble_server.descriptor.set_value: + id: test_change_descriptor + value: + data: [0x01, 0x02, 0x03] From d9adb078aa2fa4ba1db4912672d7904ea1038818 Mon Sep 17 00:00:00 2001 From: Tobias Stanzel Date: Sun, 29 Mar 2026 19:41:00 +0200 Subject: [PATCH 066/160] [tm1637] Add buffer manipulation methods (#13686) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/tm1637/tm1637.cpp | 6 ++++++ esphome/components/tm1637/tm1637.h | 3 +++ tests/components/tm1637/common.yaml | 2 ++ 3 files changed, 11 insertions(+) diff --git a/esphome/components/tm1637/tm1637.cpp b/esphome/components/tm1637/tm1637.cpp index f9c876f40c..da9adb59a4 100644 --- a/esphome/components/tm1637/tm1637.cpp +++ b/esphome/components/tm1637/tm1637.cpp @@ -348,6 +348,12 @@ uint8_t TM1637Display::print(uint8_t start_pos, const char *str) { return pos - start_pos; } uint8_t TM1637Display::print(const char *str) { return this->print(0, str); } + +void TM1637Display::set_buffer(const uint8_t *data, uint8_t length) { + uint8_t len = std::min(length, (uint8_t) sizeof(this->buffer_)); + memcpy(this->buffer_, data, len); +} + uint8_t TM1637Display::printf(uint8_t pos, const char *format, ...) { va_list arg; va_start(arg, format); diff --git a/esphome/components/tm1637/tm1637.h b/esphome/components/tm1637/tm1637.h index b9e96119e9..c1fbabb21b 100644 --- a/esphome/components/tm1637/tm1637.h +++ b/esphome/components/tm1637/tm1637.h @@ -47,6 +47,9 @@ class TM1637Display : public PollingComponent { /// Print `str` at position 0. uint8_t print(const char *str); + /// Set raw buffer bytes from data array up to length bytes. + void set_buffer(const uint8_t *data, uint8_t length); + void set_intensity(uint8_t intensity) { this->intensity_ = intensity; } void set_inverted(bool inverted) { this->inverted_ = inverted; } void set_length(uint8_t length) { this->length_ = length; } diff --git a/tests/components/tm1637/common.yaml b/tests/components/tm1637/common.yaml index 8d01e29877..b6debc055d 100644 --- a/tests/components/tm1637/common.yaml +++ b/tests/components/tm1637/common.yaml @@ -5,3 +5,5 @@ display: intensity: 3 lambda: |- it.print("1234"); + static const uint8_t buf[] = {0x3f, 0x06, 0x5b, 0x4f | 0x80}; + it.set_buffer(buf, sizeof(buf)); From a91e6d92f6e922d37283c6b9679e7ce458785716 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:32:43 -1000 Subject: [PATCH 067/160] [core] Remove dead get_loop_priority code (#15242) --- esphome/components/ch422g/ch422g.cpp | 6 ------ esphome/components/ch422g/ch422g.h | 3 --- esphome/components/ch423/ch423.cpp | 6 ------ esphome/components/ch423/ch423.h | 3 --- esphome/components/deep_sleep/deep_sleep_component.cpp | 6 ------ esphome/components/deep_sleep/deep_sleep_component.h | 3 --- esphome/components/pca9554/pca9554.cpp | 5 ----- esphome/components/pca9554/pca9554.h | 4 ---- esphome/components/pcf8574/pcf8574.cpp | 5 ----- esphome/components/pcf8574/pcf8574.h | 3 --- esphome/components/status_led/light/status_led_light.h | 3 --- esphome/components/status_led/status_led.cpp | 3 --- esphome/components/status_led/status_led.h | 3 --- esphome/components/wifi/wifi_component.cpp | 6 ------ esphome/components/wifi/wifi_component.h | 4 ---- esphome/core/application.cpp | 6 ------ esphome/core/component.cpp | 4 ---- esphome/core/component.h | 10 ---------- esphome/core/defines.h | 1 - 19 files changed, 84 deletions(-) diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index 5f5e848c76..fc856cd563 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -124,12 +124,6 @@ bool CH422GComponent::write_outputs_() { float CH422GComponent::get_setup_priority() const { return setup_priority::IO; } -#ifdef USE_LOOP_PRIORITY -// Run our loop() method very early in the loop, so that we cache read values -// before other components call our digital_read() method. -float CH422GComponent::get_loop_priority() const { return 9.0f; } // Just after WIFI -#endif - void CH422GGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool CH422GGPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } diff --git a/esphome/components/ch422g/ch422g.h b/esphome/components/ch422g/ch422g.h index 1b96568209..6e6bdad64a 100644 --- a/esphome/components/ch422g/ch422g.h +++ b/esphome/components/ch422g/ch422g.h @@ -23,9 +23,6 @@ class CH422GComponent : public Component, public i2c::I2CDevice { void pin_mode(uint8_t pin, gpio::Flags flags); float get_setup_priority() const override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif void dump_config() override; protected: diff --git a/esphome/components/ch423/ch423.cpp b/esphome/components/ch423/ch423.cpp index 805d8df877..8424d130b4 100644 --- a/esphome/components/ch423/ch423.cpp +++ b/esphome/components/ch423/ch423.cpp @@ -129,12 +129,6 @@ bool CH423Component::write_outputs_() { float CH423Component::get_setup_priority() const { return setup_priority::IO; } -#ifdef USE_LOOP_PRIORITY -// Run our loop() method very early in the loop, so that we cache read values -// before other components call our digital_read() method. -float CH423Component::get_loop_priority() const { return 9.0f; } // Just after WIFI -#endif - void CH423GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool CH423GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } diff --git a/esphome/components/ch423/ch423.h b/esphome/components/ch423/ch423.h index d85648a8f9..d384971a72 100644 --- a/esphome/components/ch423/ch423.h +++ b/esphome/components/ch423/ch423.h @@ -22,9 +22,6 @@ class CH423Component : public Component, public i2c::I2CDevice { void pin_mode(uint8_t pin, gpio::Flags flags); float get_setup_priority() const override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif void dump_config() override; protected: diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index 0511518419..3dd1b70930 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -40,12 +40,6 @@ void DeepSleepComponent::loop() { this->begin_sleep(); } -#ifdef USE_LOOP_PRIORITY -float DeepSleepComponent::get_loop_priority() const { - return -100.0f; // run after everything else is ready -} -#endif - void DeepSleepComponent::set_sleep_duration(uint32_t time_ms) { this->sleep_duration_ = uint64_t(time_ms) * 1000; } void DeepSleepComponent::set_run_duration(uint32_t time_ms) { this->run_duration_ = time_ms; } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 14713d51a1..9090f91876 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -113,9 +113,6 @@ class DeepSleepComponent : public Component { void setup() override; void dump_config() override; void loop() override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif float get_setup_priority() const override; /// Helper to enter deep sleep mode diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index d94767ef07..adc7bc0fb5 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -122,11 +122,6 @@ bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) { float PCA9554Component::get_setup_priority() const { return setup_priority::IO; } -#ifdef USE_LOOP_PRIORITY -// Run our loop() method early to invalidate cache before any other components access the pins -float PCA9554Component::get_loop_priority() const { return 9.0f; } // Just after WIFI -#endif - void PCA9554GPIOPin::setup() { pin_mode(flags_); } void PCA9554GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCA9554GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 6dd15ccb4b..1d877f9ce2 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -23,10 +23,6 @@ class PCA9554Component : public Component, float get_setup_priority() const override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif - void dump_config() override; void set_pin_count(size_t pin_count) { this->pin_count_ = pin_count; } diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index fa9496e7e4..d3ec31436d 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -99,11 +99,6 @@ bool PCF8574Component::write_gpio_() { } float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } -#ifdef USE_LOOP_PRIORITY -// Run our loop() method early to invalidate cache before any other components access the pins -float PCF8574Component::get_loop_priority() const { return 9.0f; } // Just after WIFI -#endif - void PCF8574GPIOPin::setup() { pin_mode(flags_); } void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 23bccc26c9..b039173789 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -26,9 +26,6 @@ class PCF8574Component : public Component, void pin_mode(uint8_t pin, gpio::Flags flags); float get_setup_priority() const override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif void dump_config() override; diff --git a/esphome/components/status_led/light/status_led_light.h b/esphome/components/status_led/light/status_led_light.h index a5c98d90d4..3a745e0017 100644 --- a/esphome/components/status_led/light/status_led_light.h +++ b/esphome/components/status_led/light/status_led_light.h @@ -30,9 +30,6 @@ class StatusLEDLightOutput : public light::LightOutput, public Component { void dump_config() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override { return 50.0f; } -#endif protected: GPIOPin *pin_{nullptr}; diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index 93a8d4b38e..a792110eeb 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -28,9 +28,6 @@ void StatusLED::loop() { } } float StatusLED::get_setup_priority() const { return setup_priority::HARDWARE; } -#ifdef USE_LOOP_PRIORITY -float StatusLED::get_loop_priority() const { return 50.0f; } -#endif } // namespace status_led } // namespace esphome diff --git a/esphome/components/status_led/status_led.h b/esphome/components/status_led/status_led.h index f262eb260c..a4b5db93d7 100644 --- a/esphome/components/status_led/status_led.h +++ b/esphome/components/status_led/status_led.h @@ -14,9 +14,6 @@ class StatusLED : public Component { void dump_config() override; void loop() override; float get_setup_priority() const override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif protected: GPIOPin *pin_; diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 620d1a083d..db20332667 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -970,12 +970,6 @@ void WiFiComponent::set_ap(const WiFiAP &ap) { } #endif // USE_WIFI_AP -#ifdef USE_LOOP_PRIORITY -float WiFiComponent::get_loop_priority() const { - return 10.0f; // before other loop components -} -#endif - void WiFiComponent::init_sta(size_t count) { this->sta_.init(count); } void WiFiComponent::add_sta(const WiFiAP &ap) { this->sta_.push_back(ap); } void WiFiComponent::clear_sta() { diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 55e532c37d..8dfe5fa7af 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -463,10 +463,6 @@ class WiFiComponent final : public Component { void restart_adapter(); /// WIFI setup_priority. float get_setup_priority() const override; -#ifdef USE_LOOP_PRIORITY - float get_loop_priority() const override; -#endif - /// Reconnect WiFi if required. void loop() override; diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index ce15aed1e2..5cb8a5bb24 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -99,12 +99,6 @@ void Application::setup() { if (component->can_proceed()) continue; -#ifdef USE_LOOP_PRIORITY - // Sort components 0 through i by loop priority - insertion_sort_by_prioritycomponents_.begin()), &Component::get_loop_priority>( - this->components_.begin(), this->components_.begin() + i + 1); -#endif - do { uint8_t new_app_state = STATUS_LED_WARNING; uint32_t now = millis(); diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index caaea89143..2ad82e1172 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -85,10 +85,6 @@ void store_component_error_message(const Component *component, const char *messa static constexpr uint16_t WARN_IF_BLOCKING_INCREMENT_MS = 10U; ///< How long the blocking time must be larger to warn again -#ifdef USE_LOOP_PRIORITY -float Component::get_loop_priority() const { return 0.0f; } -#endif - float Component::get_setup_priority() const { return setup_priority::DATA; } void Component::setup() {} diff --git a/esphome/core/component.h b/esphome/core/component.h index 46cd77b034..d08b1abfcd 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -115,16 +115,6 @@ class Component { void set_setup_priority(float priority); - /** priority of loop(). higher -> executed earlier - * - * Defaults to 0. - * - * @return The loop priority of this component - */ -#ifdef USE_LOOP_PRIORITY - virtual float get_loop_priority() const; -#endif - void call(); virtual void on_shutdown() {} diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8cf331c4d6..7259167a52 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -357,7 +357,6 @@ #ifdef USE_RP2040 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) -#define USE_LOOP_PRIORITY #define USE_RP2040_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C From 8a802ca666b608426644bfa2fc4caf7e0ab72577 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:54:07 -1000 Subject: [PATCH 068/160] [benchmark] Add BLE raw advertisement proto encode benchmarks (#15289) --- script/cpp_benchmark.py | 5 ++ tests/benchmarks/components/api/__init__.py | 14 +++ .../components/api/bench_proto_encode.cpp | 89 +++++++++++++++++++ .../bluetooth_proxy/bluetooth_proxy.h | 38 ++++++++ 4 files changed, 146 insertions(+) create mode 100644 tests/benchmarks/stubs/esphome/components/bluetooth_proxy/bluetooth_proxy.h diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index a54d3752df..92faa05819 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -21,6 +21,10 @@ BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "components" # Path to /tests/benchmarks/core (always included, not a component) CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" +# Stub headers for ESP32-only components (e.g. bluetooth_proxy) that +# allow benchmarks to compile on the host platform. +STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs" + PLATFORMIO_OPTIONS = { "build_unflags": [ "-Os", # remove default size-opt @@ -29,6 +33,7 @@ PLATFORMIO_OPTIONS = { "-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo) "-g", # debug symbols for profiling "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() + f"-I{STUBS_DIR}", # stub headers for ESP32-only components ], # Use deep+ LDF mode to ensure PlatformIO detects the benchmark # library dependency from nested includes. diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 0687c3f87f..eb86492964 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -1,3 +1,4 @@ +import esphome.codegen as cg from tests.testing_helpers import ComponentManifestOverride @@ -5,3 +6,16 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # api must run its to_code to define USE_API, USE_API_PLAINTEXT, # and add the noise-c library dependency. manifest.enable_codegen() + + original_to_code = manifest.to_code + + async def to_code(config): + await original_to_code(config) + # Enable BLE proto message types for benchmarks. The real + # bluetooth_proxy component is ESP32-only; a lightweight stub + # header in tests/benchmarks/stubs/ satisfies the include. + cg.add_define("USE_BLUETOOTH_PROXY") + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3) + cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + + manifest.to_code = to_code diff --git a/tests/benchmarks/components/api/bench_proto_encode.cpp b/tests/benchmarks/components/api/bench_proto_encode.cpp index 656c1e17db..1e2efcd281 100644 --- a/tests/benchmarks/components/api/bench_proto_encode.cpp +++ b/tests/benchmarks/components/api/bench_proto_encode.cpp @@ -295,4 +295,93 @@ static void CalcAndEncode_DeviceInfoResponse_Fresh(benchmark::State &state) { } BENCHMARK(CalcAndEncode_DeviceInfoResponse_Fresh); +// --- BluetoothLERawAdvertisementsResponse (12 adverts, highest-volume BLE message) --- + +#ifdef USE_BLUETOOTH_PROXY + +static BluetoothLERawAdvertisementsResponse make_ble_raw_advs_12() { + static const uint8_t fake_adv_data[] = { + 0x02, 0x01, 0x06, 0x03, 0x03, 0x9F, 0xFE, 0x17, 0x16, 0x9F, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + BluetoothLERawAdvertisementsResponse msg; + msg.advertisements_len = 12; + for (int i = 0; i < 12; i++) { + auto &adv = msg.advertisements[i]; + adv.address = 0xAABBCCDD0000ULL + i; + adv.rssi = -60 - i; + adv.address_type = 1; + memcpy(adv.data, fake_adv_data, sizeof(fake_adv_data)); + adv.data_len = sizeof(fake_adv_data); + } + return msg; +} + +static void CalculateSize_BLERawAdvs12(benchmark::State &state) { + auto msg = make_ble_raw_advs_12(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_BLERawAdvs12); + +static void Encode_BLERawAdvs12(benchmark::State &state) { + auto msg = make_ble_raw_advs_12(); + APIBuffer buffer; + uint32_t total_size = msg.calculate_size(); + buffer.resize(total_size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_BLERawAdvs12); + +static void CalcAndEncode_BLERawAdvs12(benchmark::State &state) { + auto msg = make_ble_raw_advs_12(); + APIBuffer buffer; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_BLERawAdvs12); + +static void CalcAndEncode_BLERawAdvs12_Fresh(benchmark::State &state) { + auto msg = make_ble_raw_advs_12(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + benchmark::DoNotOptimize(buffer.data()); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_BLERawAdvs12_Fresh); + +#endif // USE_BLUETOOTH_PROXY + } // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/stubs/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/tests/benchmarks/stubs/esphome/components/bluetooth_proxy/bluetooth_proxy.h new file mode 100644 index 0000000000..0934e0d4ed --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -0,0 +1,38 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp needs when USE_BLUETOOTH_PROXY is defined, +// without pulling in ESP32 BLE dependencies. +#pragma once + +#include "esphome/components/api/api_pb2.h" + +namespace esphome { +namespace api { +class APIConnection; +} // namespace api + +namespace bluetooth_proxy { + +class BluetoothProxy { + public: + api::APIConnection *get_api_connection() const { return nullptr; } + void subscribe_api_connection(api::APIConnection *conn, uint32_t flags) {} + void unsubscribe_api_connection(api::APIConnection *conn) {} + void bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {} + void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {} + void bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {} + void bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {} + void bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {} + void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {} + void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {} + void send_connections_free(api::APIConnection *conn) {} + void bluetooth_scanner_set_mode(bool active) {} + void bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {} + uint32_t get_feature_flags() const { return 0; } + void get_bluetooth_mac_address_pretty(char *buf) const { buf[0] = '\0'; } +}; + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern BluetoothProxy *global_bluetooth_proxy; + +} // namespace bluetooth_proxy +} // namespace esphome From 1f3fd60d294eed3162328520077119541666101f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:55:39 -1000 Subject: [PATCH 069/160] [version] Remove duplicate build_info_data.h include (#15288) --- esphome/components/version/version_text_sensor.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 8aec98d2da..34c7aae6bc 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -1,6 +1,5 @@ #include "version_text_sensor.h" #include "esphome/core/application.h" -#include "esphome/core/build_info_data.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -36,7 +35,9 @@ void VersionTextSensor::setup() { if (!this->hide_timestamp_) { size_t len = strlen(version_str); ESPHOME_strncat_P(version_str, BUILT_STR, sizeof(version_str) - len - 1); - ESPHOME_strncat_P(version_str, ESPHOME_BUILD_TIME_STR, sizeof(version_str) - strlen(version_str) - 1); + char build_time_buf[Application::BUILD_TIME_STR_SIZE]; + App.get_build_time_string(build_time_buf); + strncat(version_str, build_time_buf, sizeof(version_str) - strlen(version_str) - 1); } // The closing parenthesis is part of the config-hash suffix and must From 2a97eca00b82e8ef10c5203f9b1865ac6c5cc6de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:55:52 -1000 Subject: [PATCH 070/160] [sensor] Use std::array in ValueList/FilterOut/ThrottleWithPriority filters (#15265) --- esphome/components/sensor/__init__.py | 6 ++-- esphome/components/sensor/filter.cpp | 38 ++++++-------------- esphome/components/sensor/filter.h | 51 +++++++++++++++++++-------- esphome/core/helpers.h | 18 ++++++++++ 4 files changed, 70 insertions(+), 43 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 19d03a0afc..5569567de1 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -381,7 +381,7 @@ async def filter_out_filter_to_code(config, filter_id): if not isinstance(config, list): config = [config] template_ = [await cg.templatable(x, [], float) for x in config] - return cg.new_Pvariable(filter_id, template_) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(template_)), template_) QUANTILE_SCHEMA = cv.All( @@ -650,7 +650,9 @@ async def throttle_with_priority_filter_to_code(config, filter_id): if not isinstance(config[CONF_VALUE], list): config[CONF_VALUE] = [config[CONF_VALUE]] template_ = [await cg.templatable(x, [], float) for x in config[CONF_VALUE]] - return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) + return cg.new_Pvariable( + filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ + ) HEARTBEAT_SCHEMA = cv.Schema( diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index d995ee4111..66a9e9555b 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -222,16 +222,14 @@ MultiplyFilter::MultiplyFilter(TemplatableValue multiplier) : multiplier_ optional MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); } -// ValueListFilter (base class) -ValueListFilter::ValueListFilter(std::initializer_list> values) : values_(values) {} - -bool ValueListFilter::value_matches_any_(float sensor_value) { - int8_t accuracy = this->parent_->get_accuracy_decimals(); +// ValueListFilter helper (non-template, shared by all ValueListFilter instantiations) +bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableValue *values, size_t count) { + int8_t accuracy = parent->get_accuracy_decimals(); float accuracy_mult = pow10_int(accuracy); float rounded_sensor = roundf(accuracy_mult * sensor_value); - for (auto &filter_value : this->values_) { - float fv = filter_value.value(); + for (size_t i = 0; i < count; i++) { + float fv = values[i].value(); // Handle NaN comparison if (std::isnan(fv)) { @@ -248,16 +246,6 @@ bool ValueListFilter::value_matches_any_(float sensor_value) { return false; } -// FilterOutValueFilter -FilterOutValueFilter::FilterOutValueFilter(std::initializer_list> values_to_filter_out) - : ValueListFilter(values_to_filter_out) {} - -optional FilterOutValueFilter::new_value(float value) { - if (this->value_matches_any_(value)) - return {}; // Filter out - return value; // Pass through -} - // ThrottleFilter ThrottleFilter::ThrottleFilter(uint32_t min_time_between_inputs) : min_time_between_inputs_(min_time_between_inputs) {} optional ThrottleFilter::new_value(float value) { @@ -269,17 +257,13 @@ optional ThrottleFilter::new_value(float value) { return {}; } -// ThrottleWithPriorityFilter -ThrottleWithPriorityFilter::ThrottleWithPriorityFilter( - uint32_t min_time_between_inputs, std::initializer_list> prioritized_values) - : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} - -optional ThrottleWithPriorityFilter::new_value(float value) { +// ThrottleWithPriorityFilter helper (non-template, keeps App access in .cpp) +optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableValue *values, + size_t count, uint32_t &last_input, uint32_t min_time_between_inputs) { const uint32_t now = App.get_loop_component_start_time(); - // Allow value through if: no previous input, time expired, or is prioritized - if (this->last_input_ == 0 || now - this->last_input_ >= min_time_between_inputs_ || - this->value_matches_any_(value)) { - this->last_input_ = now; + if (last_input == 0 || now - last_input >= min_time_between_inputs || + value_list_matches_any(parent, value, values, count)) { + last_input = now; return value; } return {}; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 6a76bd373e..80fa14742c 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -3,6 +3,7 @@ #include "esphome/core/defines.h" #ifdef USE_SENSOR_FILTER +#include #include #include #include "esphome/core/automation.h" @@ -328,28 +329,42 @@ class MultiplyFilter : public Filter { TemplatableValue multiplier_; }; -/** Base class for filters that compare sensor values against a list of configured values. +/// Non-template helper for value matching (implementation in filter.cpp) +bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableValue *values, size_t count); + +/** Base class for filters that compare sensor values against a fixed list of configured values. * - * This base class provides common functionality for filters that need to check if a sensor - * value matches any value in a configured list, with proper handling of NaN values and - * accuracy-based rounding for comparisons. + * Templated on N (the number of values) so the list is stored inline in a std::array, + * avoiding heap allocation and the overhead of FixedVector. + * + * @tparam N Number of values in the filter list, set by code generation to match + * the exact number of values configured in YAML. */ -class ValueListFilter : public Filter { +template class ValueListFilter : public Filter { protected: - explicit ValueListFilter(std::initializer_list> values); + explicit ValueListFilter(std::initializer_list> values) { + init_array_from(this->values_, values); + } /// Check if sensor value matches any configured value (with accuracy rounding) - bool value_matches_any_(float sensor_value); + bool value_matches_any_(float sensor_value) { + return value_list_matches_any(this->parent_, sensor_value, this->values_.data(), N); + } - FixedVector> values_; + std::array, N> values_{}; }; /// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`. -class FilterOutValueFilter : public ValueListFilter { +template class FilterOutValueFilter : public ValueListFilter { public: - explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out); + explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out) + : ValueListFilter(values_to_filter_out) {} - optional new_value(float value) override; + optional new_value(float value) override { + if (this->value_matches_any_(value)) + return {}; // Filter out + return value; // Pass through + } }; class ThrottleFilter : public Filter { @@ -363,13 +378,21 @@ class ThrottleFilter : public Filter { uint32_t min_time_between_inputs_; }; +/// Non-template helper for ThrottleWithPriorityFilter (implementation in filter.cpp) +optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableValue *values, + size_t count, uint32_t &last_input, uint32_t min_time_between_inputs); + /// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`. -class ThrottleWithPriorityFilter : public ValueListFilter { +template class ThrottleWithPriorityFilter : public ValueListFilter { public: explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, - std::initializer_list> prioritized_values); + std::initializer_list> prioritized_values) + : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} - optional new_value(float value) override; + optional new_value(float value) override { + return throttle_with_priority_new_value(this->parent_, value, this->values_.data(), N, this->last_input_, + this->min_time_between_inputs_); + } protected: uint32_t last_input_{0}; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 82c6b3833c..913614f564 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -497,6 +498,23 @@ template::max()> index_type capacity_{0}; }; +/// Initialize a std::array from an initializer_list. Uses memcpy for trivially copyable types (optimal codegen), +/// falls back to element-wise copy for non-trivially copyable types (e.g. TemplatableValue). +/// N is set by code generation; assert catches mismatches in debug/integration tests. +template inline void init_array_from(std::array &dest, std::initializer_list src) { +#ifdef ESPHOME_DEBUG + assert(src.size() == N); +#endif + if constexpr (std::is_trivially_copyable_v) { + __builtin_memcpy(dest.data(), src.begin(), N * sizeof(T)); + } else { + size_t i = 0; + for (const auto &v : src) { + dest[i++] = v; + } + } +} + /// Fixed-capacity vector - allocates once at runtime, never reallocates /// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) /// when size is known at initialization but not at compile time From 5da3253f4b67128b991c7631b9730488072f9a6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:57:52 -1000 Subject: [PATCH 071/160] [esp8266] Add enable_scanf_float option (#15284) --- esphome/components/esp8266/__init__.py | 62 +++++++++++++++---- .../components/esp8266/test.esp8266-ard.yaml | 3 + tests/unit_tests/components/test_esp8266.py | 62 +++++++++++++++++++ 3 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 tests/unit_tests/components/test_esp8266.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 16043b6d69..2081145096 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +import re import esphome.codegen as cg import esphome.config_validation as cv @@ -18,8 +19,9 @@ from esphome.const import ( PLATFORM_ESP8266, ThreadModel, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority from esphome.helpers import copy_file_if_changed +from esphome.types import ConfigType from .boards import BOARDS, ESP8266_LD_SCRIPTS from .const import ( @@ -40,12 +42,42 @@ from .const import ( ) from .gpio import PinInitialState, add_pin_initial_states_array +CONF_ENABLE_SCANF_FLOAT = "enable_scanf_float" +# Heuristically matches scanf/sscanf calls with float format specifiers. +# Standard scanf float conversions: %f %F %e %E %g %G %a %A +# With optional modifiers: %*f (suppression), %8f (width), %lf %Lf (length) +# Also matches non-standard patterns like %.2f as a heuristic — these are +# invalid in scanf but users may write them by analogy with printf. +# Uses [^;]*? to stay within a single statement, preventing false positives +# from e.g. sscanf(buf, "%d", &x); printf("%f", val); +_SCANF_FLOAT_RE = re.compile(r"scanf\s*\([^;]*?%[*\d.]*[hlL]*[feEgGaAF]") + CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["preferences"] IS_TARGET_PLATFORM = True +def lambdas_use_scanf_float(config: ConfigType) -> bool: + """Check if any lambda in the config uses scanf with a float format specifier. + + Comments are stripped before matching to avoid false positives from + commented-out code. The cost of a false positive is only ~8KB flash. + """ + stack: list = [config] + while stack: + obj = stack.pop() + if isinstance(obj, Lambda): + src = obj.comment_remover(obj.value) + if _SCANF_FLOAT_RE.search(src): + return True + elif isinstance(obj, dict): + stack.extend(obj.values()) + elif isinstance(obj, list): + stack.extend(obj) + return False + + def set_core_data(config): CORE.data[KEY_ESP8266] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266 @@ -181,6 +213,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_SERIAL): cv.boolean, cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean, cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, + cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean, } ), set_core_data, @@ -201,16 +234,23 @@ async def to_code(config): cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define(ThreadModel.SINGLE) - cg.add_platformio_option( - "extra_scripts", - [ - "pre:testing_mode.py", - "pre:exclude_updater.py", - "pre:exclude_waveform.py", - "pre:remove_float_scanf.py", - "post:post_build.py", - ], - ) + enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT) + if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config): + enable_scanf_float = True + _LOGGER.warning( + "Lambda uses scanf with a float format specifier; " + "enabling scanf float support (~8KB flash)" + ) + + extra_scripts = [ + "pre:testing_mode.py", + "pre:exclude_updater.py", + "pre:exclude_waveform.py", + ] + if not enable_scanf_float: + extra_scripts.append("pre:remove_float_scanf.py") + extra_scripts.append("post:post_build.py") + cg.add_platformio_option("extra_scripts", extra_scripts) conf = config[CONF_FRAMEWORK] cg.add_platformio_option("framework", "arduino") diff --git a/tests/components/esp8266/test.esp8266-ard.yaml b/tests/components/esp8266/test.esp8266-ard.yaml index c77218f7a3..ba70c1a6a4 100644 --- a/tests/components/esp8266/test.esp8266-ard.yaml +++ b/tests/components/esp8266/test.esp8266-ard.yaml @@ -14,3 +14,6 @@ esphome: assert(x == 95); x = clamp_at_most(x, 40); assert(x == 40); + - lambda: |- + float value = 0.0f; + sscanf("3.14", "%f", &value); diff --git a/tests/unit_tests/components/test_esp8266.py b/tests/unit_tests/components/test_esp8266.py new file mode 100644 index 0000000000..318fd2d889 --- /dev/null +++ b/tests/unit_tests/components/test_esp8266.py @@ -0,0 +1,62 @@ +"""Tests for ESP8266 component.""" + +import pytest + +from esphome.components.esp8266 import lambdas_use_scanf_float +from esphome.core import Lambda +from esphome.types import ConfigType + + +@pytest.mark.parametrize( + ("src", "expected"), + [ + # Basic float formats + ('sscanf(buf, "%f", &v)', True), + ('sscanf(buf, "%F", &v)', True), + ('sscanf(buf, "%e", &v)', True), + ('sscanf(buf, "%E", &v)', True), + ('sscanf(buf, "%g", &v)', True), + ('sscanf(buf, "%G", &v)', True), + ('sscanf(buf, "%a", &v)', True), + ('sscanf(buf, "%A", &v)', True), + # With modifiers + ('sscanf(buf, "%lf", &v)', True), + ('sscanf(buf, "%Lf", &v)', True), + ('sscanf(buf, "%8lf", &v)', True), + ('sscanf(buf, "%*f")', True), + ('sscanf(buf, "%.2f", &v)', True), + # Mixed formats + ('sscanf(buf, "%d,%f", &a, &b)', True), + # fscanf and std::sscanf + ('fscanf(fp, "%f", &v)', True), + ('std::sscanf(buf, "%f", &v)', True), + # Multi-line + ('sscanf(buf,\n"%f", &v)', True), + # No float format + ('sscanf(buf, "%d", &v)', False), + ('sscanf(buf, "%s", s)', False), + # printf not scanf + ('printf("%f", val)', False), + # %f in a different statement after scanf + ('sscanf(buf, "%d", &x); printf("%f", val);', False), + # scanf %f in comment only + ('// sscanf(buf, "%f", &v)\nsscanf(buf, "%d", &x)', False), + ('/* sscanf(buf, "%f") */\nsscanf(buf, "%d", &x)', False), + ], +) +def test_lambdas_use_scanf_float(src: str, expected: bool) -> None: + """Test scanf float detection in lambda source.""" + config: ConfigType = {"test": [Lambda(src)]} + assert lambdas_use_scanf_float(config) is expected + + +def test_lambdas_use_scanf_float_no_lambdas() -> None: + """Test with config containing no lambdas.""" + config: ConfigType = {"key": "value", "list": [1, 2]} + assert lambdas_use_scanf_float(config) is False + + +def test_lambdas_use_scanf_float_nested() -> None: + """Test detection in deeply nested config.""" + config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}} + assert lambdas_use_scanf_float(config) is True From 584807b03900ea488f46fde1cd3a1cd13234bed6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:58:03 -1000 Subject: [PATCH 072/160] [ld2410] Fix flaky integration test race condition (#15299) --- tests/integration/test_uart_mock_ld2410.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_uart_mock_ld2410.py b/tests/integration/test_uart_mock_ld2410.py index ce0e1bb7ec..88d6f2cbac 100644 --- a/tests/integration/test_uart_mock_ld2410.py +++ b/tests/integration/test_uart_mock_ld2410.py @@ -73,9 +73,16 @@ async def test_uart_mock_ld2410( ], ) - # Signal when we see recovery frame values + # Signal when we see ALL recovery frame values to avoid race where some + # arrive after the waiter fires but before we index into the lists recovery_received = collector.add_waiter( - lambda: pytest.approx(50.0) in collector.sensor_states["moving_distance"] + lambda: ( + pytest.approx(50.0) in collector.sensor_states["moving_distance"] + and pytest.approx(75.0) in collector.sensor_states["still_distance"] + and pytest.approx(100.0) in collector.sensor_states["moving_energy"] + and pytest.approx(80.0) in collector.sensor_states["still_energy"] + and pytest.approx(127.0) in collector.sensor_states["detection_distance"] + ) ) async with ( From c2b8ea33610be67f2ea7cf043e2276552d1c558a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:02:29 -1000 Subject: [PATCH 073/160] [web_server_base] Reduce sizeof(WebServerBase) by 4 bytes (#15251) --- esphome/components/web_server_base/web_server_base.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 48e13ad71e..2aa3ae215c 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -135,7 +135,7 @@ class WebServerBase { uint16_t get_port() const { return port_; } protected: - int initialized_{0}; + uint8_t initialized_{0}; uint16_t port_{80}; AsyncWebServer *server_{nullptr}; std::vector handlers_; From 38fa8925da52981021c334e6f88ba4e521208fc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:02:47 -1000 Subject: [PATCH 074/160] [ai] Add automation, callback manager, and test grouping docs (#15243) --- .ai/instructions.md | 174 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 2 deletions(-) diff --git a/.ai/instructions.md b/.ai/instructions.md index a7e08f9c4d..86f554e9ce 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -239,6 +239,123 @@ This document provides essential context for AI models interacting with this pro var = await switch.new_switch(config) ``` +* **Automations (Triggers, Actions, Conditions):** + + Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true). + + * **Triggers -- Callback method (preferred):** + + Use `build_callback_automation()` for simple triggers. This eliminates the need for a C++ Trigger class by using a lightweight pointer-sized forwarder struct registered directly as a callback. No `CONF_TRIGGER_ID` in the schema. + + **Python:** + ```python + from esphome import automation + + CONFIG_SCHEMA = cv.Schema({ + cv.GenerateID(): cv.declare_id(MyComponent), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + }).extend(cv.COMPONENT_SCHEMA) + + async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + for conf in config.get(CONF_ON_STATE, []): + await automation.build_callback_automation( + var, "add_on_state_callback", [(bool, "x")], conf + ) + ``` + + `build_callback_automation` arguments: `parent`, `callback_method` (C++ method name), `args` (template args as `[(type, name)]` tuples), `config`, and optional `forwarder` (defaults to `TriggerForwarder`). + + For boolean filtering (e.g. `on_press`/`on_release`), use built-in forwarders with `args=[]`: + ```python + for conf_key, forwarder in ( + (CONF_ON_PRESS, automation.TriggerOnTrueForwarder), + (CONF_ON_RELEASE, automation.TriggerOnFalseForwarder), + ): + for conf in config.get(conf_key, []): + await automation.build_callback_automation( + var, "add_on_state_callback", [], conf, forwarder=forwarder + ) + ``` + + **C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation): + ```cpp + class MyComponent : public Component { + public: + // Must be a template -- accepts both std::function and pointer-sized forwarder structs + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + protected: + // Use CallbackManager when callbacks are always registered (e.g. core components) + CallbackManager state_callback_; + // Use LazyCallbackManager when callbacks are often not registered -- saves 8 bytes + // (nullptr vs empty std::vector) per instance when no callbacks are added + // LazyCallbackManager state_callback_; + }; + ``` + + * **Triggers -- Trigger class method:** + + Use `build_automation()` with a `Trigger` subclass only when the forwarder needs **mutable state beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic). + + **Python:** + ```python + TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template()) + + CONFIG_SCHEMA = cv.Schema({ + cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)} + ), + }) + + async def to_code(config): + for conf in config.get(CONF_ON_TURN_ON, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + ``` + + **C++:** + ```cpp + class TurnOnTrigger : public Trigger<> { + public: + explicit TurnOnTrigger(MyComponent *parent) : last_on_{false} { + parent->add_on_state_callback([this](bool state) { + if (state && !this->last_on_) + this->trigger(); + this->last_on_ = state; + }); + } + protected: + bool last_on_; + }; + ``` + + * **Actions:** + ```cpp + template class MyAction : public Action { + public: + explicit MyAction(MyComponent *parent) : parent_(parent) {} + void play(const Ts &...) override { this->parent_->do_something(); } + protected: + MyComponent *parent_; + }; + ``` + Register with `@automation.register_action("my_component.do_something", MyAction, schema, synchronous=True)`. Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. + + * **Conditions:** + ```cpp + template class MyCondition : public Condition { + public: + explicit MyCondition(MyComponent *parent) : parent_(parent) {} + bool check(const Ts &...) override { return this->parent_->is_active(); } + protected: + MyComponent *parent_; + }; + ``` + Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`. + * **Configuration Validation:** * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`. * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`. @@ -274,10 +391,39 @@ This document provides essential context for AI models interacting with this pro * **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows: ``` tests/ - ├── test_build_components/ # Base test configurations - └── components/[component]/ # Component-specific tests + ├── test_build_components/ + │ └── common/ # Shared bus packages (uart, i2c, spi, etc.) + │ ├── uart/ # UART at default baud rate + │ ├── uart_115200/ # UART at 115200 baud + │ ├── i2c/ # I2C bus + │ └── spi/ # SPI bus + └── components/[component]/ + ├── common.yaml # Component-only config (no bus definitions) + ├── test.esp32-idf.yaml + ├── test.esp8266-ard.yaml + └── test.rp2040-ard.yaml ``` Run them using `script/test_build_components`. Use `-c ` to test specific components and `-t ` for specific platforms. + + * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`: + ```yaml + # test.esp32-idf.yaml — use packages for buses + packages: + uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + + <<: !include common.yaml + ``` + ```yaml + # common.yaml — component config only, NO bus definitions + my_component: + id: my_instance + + sensor: + - platform: my_component + name: My Sensor + ``` + Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time. + * **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use: ```bash ./script/test_component_grouping.py -e config --all @@ -417,6 +563,30 @@ This document provides essential context for AI models interacting with this pro Note: Avoiding heap allocation after `setup()` is always required regardless of component type. The prioritization above is about the effort spent on container optimization (e.g., migrating from `std::vector` to `StaticVector`). + **Callback Managers:** + + ESPHome provides two callback manager types in `esphome/core/helpers.h` for the observer pattern. Both support `std::function`, lambdas, and lightweight forwarder structs via their templatized `add()` method. + + | Type | Idle overhead (32-bit) | When to use | + |------|----------------------|-------------| + | `CallbackManager` | 12 bytes (empty `std::vector`) | Callbacks are always or almost always registered | + | `LazyCallbackManager` | 4 bytes (`nullptr`) | Callbacks are often not registered (common case) | + + `LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first callback is added. Prefer it for entity-level callbacks where most instances have no subscribers. + + **Important:** Registration methods that add to a callback manager **must always be templatized** to accept both `std::function` and pointer-sized forwarder structs (used by `build_callback_automation`). Never use `std::function` in the method signature: + ```cpp + // Bad -- forces heap allocation for forwarder structs + void add_on_state_callback(std::function &&callback) { + this->state_callback_.add(std::move(callback)); + } + + // Good -- accepts any callable without forcing std::function wrapping + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + ``` + * **State Management:** Use `CORE.data` for component state that needs to persist during configuration generation. Avoid module-level mutable globals. **Bad Pattern (Module-Level Globals):** From a9aaf29d837b9228437f3954c1b2fb2396035ce6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:09:21 -1000 Subject: [PATCH 075/160] [core] Shrink Component from 12 to 8 bytes per instance (#15103) --- esphome/core/component.cpp | 24 ++++-- esphome/core/component.h | 37 +++++--- esphome/cpp_helpers.py | 123 ++++++++++++++++++++++++++- tests/unit_tests/test_cpp_helpers.py | 72 +++++++++++++++- 4 files changed, 230 insertions(+), 26 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2ad82e1172..00a36fce3d 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -84,6 +84,8 @@ void store_component_error_message(const Component *component, const char *messa static constexpr uint16_t WARN_IF_BLOCKING_INCREMENT_MS = 10U; ///< How long the blocking time must be larger to warn again +// Threshold in ms (computed from centiseconds constant in component.h) +static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; float Component::get_setup_priority() const { return setup_priority::DATA; } @@ -268,15 +270,18 @@ void Component::call() { break; } } +const LogString *Component::get_component_log_str() const { + return component_source_lookup(this->component_source_index_); +} bool Component::should_warn_of_blocking(uint32_t blocking_time) { - if (blocking_time > this->warn_if_blocking_over_) { - // Prevent overflow when adding increment - if we're about to overflow, just max out - if (blocking_time + WARN_IF_BLOCKING_INCREMENT_MS < blocking_time || - blocking_time + WARN_IF_BLOCKING_INCREMENT_MS > std::numeric_limits::max()) { - this->warn_if_blocking_over_ = std::numeric_limits::max(); - } else { - this->warn_if_blocking_over_ = static_cast(blocking_time + WARN_IF_BLOCKING_INCREMENT_MS); - } + // Convert centisecond threshold to milliseconds for comparison + uint32_t threshold_ms = static_cast(this->warn_if_blocking_over_) * 10U; + if (blocking_time > threshold_ms) { + // Set new threshold: blocking_time + increment, converted back to centiseconds + uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS; + uint32_t new_cs = new_threshold_ms / 10U; + // Saturate at uint8_t max (255 = 2550ms) + this->warn_if_blocking_over_ = static_cast(new_cs > 255U ? 255U : new_cs); return true; } return false; @@ -537,4 +542,7 @@ void clear_setup_priority_overrides() { } #endif +// Weak default for component_source_lookup - overridden by generated code +__attribute__((weak)) const LogString *component_source_lookup(uint8_t) { return LOG_STR(""); } + } // namespace esphome diff --git a/esphome/core/component.h b/esphome/core/component.h index d08b1abfcd..c390a205f0 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -11,6 +11,10 @@ #include "esphome/core/log.h" #include "esphome/core/optional.h" +// Forward declarations for friend access from codegen-generated setup() +void setup(); // NOLINT(readability-redundant-declaration) - may be declared in Arduino.h +void original_setup(); // NOLINT(readability-redundant-declaration) + namespace esphome { // Forward declaration for LogString @@ -79,11 +83,14 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08; inline constexpr uint8_t STATUS_LED_ERROR = 0x10; // Component loop override flag uses bit 5 (set at registration time) inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20; - // Remove before 2026.8.0 enum class RetryResult { DONE, RETRY }; -inline constexpr uint16_t WARN_IF_BLOCKING_OVER_MS = 50U; +inline constexpr uint8_t WARN_IF_BLOCKING_OVER_CS = 5U; // 50ms in centiseconds (1cs = 10ms) + +/// Lookup component source name by index (1-based). Generated by Python codegen. +/// Weak default returns "" so builds without codegen still link. +const LogString *component_source_lookup(uint8_t index); class Component { public: @@ -275,23 +282,25 @@ class Component { bool has_overridden_loop() const { return (this->component_state_ & COMPONENT_HAS_LOOP) != 0; } - /** Set where this component was loaded from for some debug messages. - * - * This is set by the ESPHome core, and should not be called manually. - */ - void set_component_source(const LogString *source) { component_source_ = source; } /** Get the integration where this component was declared as a LogString for logging. * * Returns LOG_STR("") if source not set */ - const LogString *get_component_log_str() const { - return this->component_source_ == nullptr ? LOG_STR("") : this->component_source_; - } + const LogString *get_component_log_str() const; bool should_warn_of_blocking(uint32_t blocking_time); protected: friend class Application; + friend void ::setup(); + friend void ::original_setup(); + + /** Set where this component was loaded from for some debug messages. + * + * This is set by the ESPHome core during setup, and should not be called manually. + * @param index 1-based index into the component source lookup table (0 = not set) + */ + void set_component_source_(uint8_t index) { this->component_source_index_ = index; } virtual void call_setup(); void call_dump_config_(); @@ -509,9 +518,9 @@ class Component { void status_clear_warning_slow_path_(); void status_clear_error_slow_path_(); - // Ordered for optimal packing on 32-bit systems - const LogString *component_source_{nullptr}; - uint16_t warn_if_blocking_over_{WARN_IF_BLOCKING_OVER_MS}; ///< Warn if blocked for this many ms (max 65.5s) + // Ordered for optimal packing on 32-bit systems (8 bytes total with vtable) + uint8_t component_source_index_{0}; ///< Index into component source PROGMEM lookup table (0 = not set) + uint8_t warn_if_blocking_over_{WARN_IF_BLOCKING_OVER_CS}; ///< Warn threshold in centiseconds (max 2550ms) /// State of this component - each bit has a purpose: /// Bits 0-2: Component state (0x00=CONSTRUCTION, 0x01=SETUP, 0x02=LOOP, 0x03=FAILED, 0x04=LOOP_DONE) /// Bit 3: STATUS_LED_WARNING @@ -588,6 +597,8 @@ class WarnIfComponentBlockingGuard { this->record_runtime_stats_(); #endif #ifndef USE_BENCHMARK + // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) + static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { warn_blocking(this->component_, blocking_time); } diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 8f8c693140..e7ff2965c8 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field import logging from esphome.const import ( @@ -7,15 +8,130 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, KEY_PAST_SAFE_MODE, ) -from esphome.core import CORE, ID, coroutine +from esphome.core import CORE, ID, CoroPriority, coroutine, coroutine_with_priority from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import LogStringLiteral, add, add_define, get_variable +from esphome.cpp_generator import ( + RawStatement, + add, + add_define, + add_global, + get_variable, +) from esphome.cpp_types import App +from esphome.helpers import cpp_string_escape from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry _LOGGER = logging.getLogger(__name__) +_COMPONENT_SOURCE_DOMAIN = "component_source_pool" + +# Maximum unique component source names (8-bit index, 0 = not set) +_MAX_COMPONENT_SOURCES = 0xFF # 255 + + +@dataclass +class ComponentSourcePool: + """Pool of component source names for PROGMEM lookup table. + + Source names are registered during to_code() and assigned 1-based indices. + Index 0 means "not set" (returns LOG_STR("")). At render time, + the pool generates a C++ PROGMEM table + lookup function. + """ + + sources: dict[str, int] = field(default_factory=dict) + table_registered: bool = False + + +def _get_source_pool() -> ComponentSourcePool: + """Get or create the component source pool from CORE.data.""" + if _COMPONENT_SOURCE_DOMAIN not in CORE.data: + CORE.data[_COMPONENT_SOURCE_DOMAIN] = ComponentSourcePool() + return CORE.data[_COMPONENT_SOURCE_DOMAIN] + + +def _ensure_source_table_registered() -> None: + """Schedule the table generation job (once).""" + pool = _get_source_pool() + if pool.table_registered: + return + pool.table_registered = True + CORE.add_job(_generate_component_source_table) + + +def register_component_source(name: str) -> int: + """Register a component source name and return its 1-based index. + + Deduplicates: multiple components from the same source share one index. + """ + if not name: + return 0 + pool = _get_source_pool() + if name in pool.sources: + return pool.sources[name] + idx = len(pool.sources) + 1 + if idx > _MAX_COMPONENT_SOURCES: + _LOGGER.warning( + "Too many unique component source names (max %d), '%s' will show as ''", + _MAX_COMPONENT_SOURCES, + name, + ) + return 0 + pool.sources[name] = idx + _ensure_source_table_registered() + return idx + + +def _generate_source_table_code( + table_var: str, + lookup_fn: str, + strings: dict[str, int], +) -> str: + """Generate C++ PROGMEM table + LogString* lookup for component sources. + + Same pattern as entity_helpers._generate_category_code but returns + const LogString* instead of const char* (needed for LOG_STR_ARG). + """ + if not strings: + return "" + + sorted_strings = sorted(strings.items(), key=lambda x: x[1]) + count = len(sorted_strings) + + # Emit individual PROGMEM char arrays so string data lives in flash on ESP8266 + lines: list[str] = [] + var_names: list[str] = [] + for i, (s, _) in enumerate(sorted_strings): + var_name = f"{table_var}_STR_{i}" + var_names.append(var_name) + lines.append( + f"static const char {var_name}[] PROGMEM = {cpp_string_escape(s)};" + ) + + entries = ", ".join(var_names) + lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};") + lines.append(f"const LogString *{lookup_fn}(uint8_t index) {{") + lines.append(f' if (index == 0 || index > {count}) return LOG_STR("");') + lines.append(" return reinterpret_cast(") + lines.append(f" progmem_read_ptr(&{table_var}[index - 1]));") + lines.append("}") + return "\n".join(lines) + "\n" + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _generate_component_source_table() -> None: + """Generate the component source lookup table as a FINAL-priority job. + + Runs after all component to_code() calls have registered their sources. + """ + pool = _get_source_pool() + if code := _generate_source_table_code( + "COMP_SRC_TABLE", "component_source_lookup", pool.sources + ): + add_global( + RawStatement(f"namespace esphome {{\n{code}}} // namespace esphome") + ) + async def gpio_pin_expression(conf): """Generate an expression for the given pin option. @@ -77,7 +193,8 @@ async def register_component(var, config): "Error while finding name of component, please report this", exc_info=e ) if name is not None: - add(var.set_component_source(LogStringLiteral(name))) + idx = register_component_source(name) + add(var.set_component_source_(idx)) add(App.register_component_(var)) diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 5b6eed156f..52424a7cb2 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -1,8 +1,10 @@ +import logging from unittest.mock import Mock import pytest from esphome import const, cpp_helpers as ch +from esphome.cpp_helpers import ComponentSourcePool, register_component_source @pytest.mark.asyncio @@ -23,7 +25,7 @@ async def test_register_component(monkeypatch): app_mock = Mock(register_component_=Mock(return_value=var)) monkeypatch.setattr(ch, "App", app_mock) - core_mock = Mock(component_ids=["foo.bar"]) + core_mock = Mock(component_ids=["foo.bar"], data={}) monkeypatch.setattr(ch, "CORE", core_mock) add_mock = Mock() @@ -59,7 +61,7 @@ async def test_register_component__with_setup_priority(monkeypatch): app_mock = Mock(register_component_=Mock(return_value=var)) monkeypatch.setattr(ch, "App", app_mock) - core_mock = Mock(component_ids=["foo.bar"]) + core_mock = Mock(component_ids=["foo.bar"], data={}) monkeypatch.setattr(ch, "CORE", core_mock) add_mock = Mock() @@ -78,3 +80,69 @@ async def test_register_component__with_setup_priority(monkeypatch): assert add_mock.call_count == 4 app_mock.register_component_.assert_called_with(var) assert core_mock.component_ids == [] + + +def test_register_component_source_empty_name(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ch, "CORE", Mock(data={})) + assert register_component_source("") == 0 + + +def test_register_component_source_deduplicates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(ch, "CORE", Mock(data={})) + idx1 = register_component_source("wifi") + idx2 = register_component_source("api") + idx3 = register_component_source("wifi") + assert idx1 == 1 + assert idx2 == 2 + assert idx3 == 1 # deduplicated + + +def test_generate_source_table_code_empty() -> None: + from esphome.cpp_helpers import _generate_source_table_code + + assert _generate_source_table_code("TBL", "lookup", {}) == "" + + +def test_generate_source_table_code_non_empty() -> None: + from esphome.cpp_helpers import _generate_source_table_code + + code = _generate_source_table_code("TBL", "lookup", {"wifi": 1, "api": 2}) + assert "PROGMEM" in code + assert "wifi" in code + assert "api" in code + assert "lookup" in code + assert "index == 0" in code + assert "progmem_read_ptr" in code + assert "index > 2" in code + + +@pytest.mark.asyncio +async def test_generate_component_source_table_empty_pool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that _generate_component_source_table does nothing with an empty pool.""" + from esphome.cpp_helpers import _generate_component_source_table + + monkeypatch.setattr(ch, "CORE", Mock(data={})) + add_global_mock = Mock() + monkeypatch.setattr(ch, "add_global", add_global_mock) + await _generate_component_source_table() + add_global_mock.assert_not_called() + + +def test_register_component_source_overflow_warns( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + # Pre-fill pool to max + pool = ComponentSourcePool( + sources={f"comp_{i}": i + 1 for i in range(0xFF)}, + table_registered=True, + ) + monkeypatch.setattr(ch, "CORE", Mock(data={ch._COMPONENT_SOURCE_DOMAIN: pool})) + with caplog.at_level(logging.WARNING): + idx = register_component_source("overflow_component") + assert idx == 0 + assert "Too many unique component source names" in caplog.text + assert "overflow_component" in caplog.text From d6475eaeed764bb30589323f832cf305d94bd69f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:15:18 -1000 Subject: [PATCH 076/160] [binary_sensor] Remove redundant `optional` state_, save 8 bytes per instance (#15095) --- .../binary_sensor/binary_sensor.cpp | 7 -- .../components/binary_sensor/binary_sensor.h | 21 +++-- esphome/core/entity_base.h | 88 +++++++++++++------ 3 files changed, 77 insertions(+), 39 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 8ace7eafd1..7596975a68 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -32,13 +32,6 @@ void BinarySensor::publish_initial_state(bool new_state) { this->invalidate_state(); this->publish_state(new_state); } -void BinarySensor::send_state_internal(bool new_state) { - // copy the new state to the visible property for backwards compatibility, before any callbacks - this->state = new_state; - // Note that set_new_state_ de-dups and will only trigger callbacks if the state has actually changed - this->set_new_state(new_state); -} - bool BinarySensor::set_new_state(const optional &new_state) { if (StatefulEntityBase::set_new_state(new_state)) { // weirdly, this file could be compiled even without USE_BINARY_SENSOR defined diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 6ae5d04bcb..28c156763a 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -32,7 +32,10 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi */ class BinarySensor : public StatefulEntityBase { public: - explicit BinarySensor(){}; + explicit BinarySensor() = default; + + const bool &get_state() const override { return this->state; } + void set_trigger_on_initial_state(bool value) { this->trigger_on_initial_state_ = value; } /** Publish a new state to the front-end. * @@ -54,16 +57,24 @@ class BinarySensor : public StatefulEntityBase { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) - void send_state_internal(bool new_state); + void send_state_internal(bool new_state) { + // Fast path: skip virtual dispatch when state hasn't changed + if (this->flags_.has_state && this->state == new_state) + return; + this->set_new_state(new_state); + } /// Return whether this binary sensor has outputted a state. virtual bool is_status_binary_sensor() const; - // For backward compatibility, provide an accessible property - + /// The current state of this binary sensor. Also used as the backing storage for StatefulEntityBase. bool state{}; protected: + bool get_trigger_on_initial_state() const override { return this->trigger_on_initial_state_; } + void set_state_value(const bool &value) override { this->state = value; } + + bool trigger_on_initial_state_{true}; #ifdef USE_BINARY_SENSOR_FILTER Filter *filter_list_{nullptr}; #endif @@ -73,7 +84,7 @@ class BinarySensor : public StatefulEntityBase { class BinarySensorInitiallyOff : public BinarySensor { public: - bool has_state() const override { return true; } + BinarySensorInitiallyOff() { this->set_has_state(true); } }; } // namespace esphome::binary_sensor diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 8c1f1a213e..5a69c9dd09 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -296,15 +296,36 @@ void log_entity_device_class(const char *tag, const char *prefix, const EntityBa #define LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, obj) log_entity_unit_of_measurement(tag, prefix, obj) void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase &obj); -/** - * An entity that has a state. - * @tparam T The type of the state +/** Base class for entities that track a typed state value with change-detection and callbacks. + * + * This class does not store the state value — subclasses own their storage. Whether a state + * has been set is tracked by EntityBase::has_state(). + * + * Subclasses must implement: + * - get_state(): return a const reference to the current value + * - set_state_value(): store a new value (called only when the state actually changes) + * - get_trigger_on_initial_state(): return whether callbacks should fire on the first state + * + * Subclasses may override set_new_state() to add behavior (logging, notifications) after calling + * the base implementation. Since set_new_state() is virtual, callers like invalidate_state() + * dispatch through the vtable to the subclass override in the .cpp, avoiding template code + * bloat at inline call sites. Subclasses may also add a fast-path dedup check before calling + * set_new_state() to skip virtual dispatch entirely when the state hasn't changed. + * + * Callback behavior: + * - full_state_callbacks_: fired on every change, receives optional previous and current + * - state_callbacks_: fired only when the new state has a value, and either this is not the + * first state (had_state) or trigger_on_initial_state is set + * + * @tparam T The type of the state value */ template class StatefulEntityBase : public EntityBase { public: - virtual bool has_state() const { return this->state_.has_value(); } - virtual const T &get_state() const { return this->state_.value(); } // NOLINT(bugprone-unchecked-optional-access) - virtual T get_state_default(T default_value) const { return this->state_.value_or(default_value); } + /// Return the current state value. Only valid when has_state() is true. + virtual const T &get_state() const = 0; + /// Return the current state if available, otherwise return the provided default. + T get_state_default(T default_value) const { return this->has_state() ? this->get_state() : default_value; } + /// Clear the state — sets has_state() to false and fires callbacks with nullopt. void invalidate_state() { this->set_new_state({}); } template void add_full_state_callback(F &&callback) { @@ -314,33 +335,46 @@ template class StatefulEntityBase : public EntityBase { this->state_callbacks_.add(std::forward(callback)); } - void set_trigger_on_initial_state(bool trigger_on_initial_state) { - this->trigger_on_initial_state_ = trigger_on_initial_state; - } - protected: - optional state_{}; - /** - * Set a new state for this entity. This will trigger callbacks only if the new state is different from the previous. + /// Subclasses return whether callbacks should fire on the very first state. + virtual bool get_trigger_on_initial_state() const = 0; + + /** Apply a new state, de-duplicating and firing callbacks as needed. * - * @param new_state The new state. - * @return True if the state was changed, false if it was the same as before. + * Pass nullopt to invalidate (clear) the state. Pass a value to set it. + * Returns true if the state actually changed, false if it was the same. + * Subclasses may override to add logging/notifications after calling the base. */ virtual bool set_new_state(const optional &new_state) { - if (this->state_ != new_state) { - // call the full state callbacks with the previous and new state - this->full_state_callbacks_.call(this->state_, new_state); - // trigger legacy callbacks only if the new state is valid and either the trigger on initial state is enabled or - // the previous state was valid - auto had_state = this->has_state(); - this->state_ = new_state; - if (new_state.has_value() && (this->trigger_on_initial_state_ || had_state)) - this->state_callbacks_.call(new_state.value()); - return true; + // Access flags_ directly to avoid function call overhead in this hot path + bool had_state = this->flags_.has_state; + // Use pointer to avoid requiring T to be default-constructible + const T *current = had_state ? &this->get_state() : nullptr; + if (new_state.has_value()) { + if (current != nullptr && *current == new_state.value()) + return false; // same value, no change + } else if (!had_state) { + return false; // already invalidated, no change } - return false; + // Capture old_state before set_state_value — current pointer aliases subclass storage + bool has_full_cbs = !this->full_state_callbacks_.empty(); + optional old_state; + if (has_full_cbs) + old_state = current != nullptr ? optional(*current) : nullopt; + // Update storage before firing callbacks so callback code can inspect current state + this->flags_.has_state = new_state.has_value(); + if (new_state.has_value()) { + this->set_state_value(new_state.value()); + } + if (has_full_cbs) + this->full_state_callbacks_.call(old_state, new_state); + // had_state first: on every change except the first, skips the virtual call + if (new_state.has_value() && (had_state || this->get_trigger_on_initial_state())) + this->state_callbacks_.call(new_state.value()); + return true; } - bool trigger_on_initial_state_{true}; + /// Subclasses implement this to store the actual value into their own storage. + virtual void set_state_value(const T &value) = 0; LazyCallbackManager previous, optional current)> full_state_callbacks_; LazyCallbackManager state_callbacks_; }; From 3520ef74809b0d6f1c6e9d3abd067c36536ce9a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:38:04 -1000 Subject: [PATCH 077/160] [text_sensor] Use std::array in MapFilter (#15269) --- esphome/components/text_sensor/__init__.py | 2 +- esphome/components/text_sensor/filter.cpp | 14 ++++++-------- esphome/components/text_sensor/filter.h | 17 +++++++++++++---- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 51eedf9a95..78a7a3a41b 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -129,7 +129,7 @@ async def map_filter_to_code(config, filter_id): ) for conf in config ] - return cg.new_Pvariable(filter_id, mappings) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(mappings)), mappings) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index f7c6a695fb..bc044f3a73 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -93,17 +93,15 @@ bool SubstituteFilter::new_value(std::string &value) { return true; } -// Map -MapFilter::MapFilter(const std::initializer_list &mappings) : mappings_(mappings) {} - -bool MapFilter::new_value(std::string &value) { - for (const auto &mapping : this->mappings_) { - if (value == mapping.from) { - value.assign(mapping.to); +// Map — non-template helper +bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value) { + for (size_t i = 0; i < count; i++) { + if (value == mappings[i].from) { + value.assign(mappings[i].to); return true; } } - return true; // Pass through if no match + return true; } } // namespace esphome::text_sensor diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 8a8bc55c8e..07832af9e2 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -3,6 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_TEXT_SENSOR_FILTER +#include + #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -131,6 +133,9 @@ class SubstituteFilter : public Filter { FixedVector substitutions_; }; +/// Non-template helper (implementation in filter.cpp) +bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value); + /** A filter that maps values from one set to another * * Uses linear search instead of std::map for typical small datasets (2-20 mappings). @@ -154,14 +159,18 @@ class SubstituteFilter : public Filter { * - Faster for typical ESPHome usage (2-10 mappings common, 20+ rare) * * Break-even point: ~35-40 mappings, but ESPHome configs rarely exceed 20 + * + * N is set by code generation to match the exact number of mappings configured in YAML. */ -class MapFilter : public Filter { +template class MapFilter : public Filter { public: - explicit MapFilter(const std::initializer_list &mappings); - bool new_value(std::string &value) override; + explicit MapFilter(const std::initializer_list &mappings) { + init_array_from(this->mappings_, mappings); + } + bool new_value(std::string &value) override { return map_filter_apply(this->mappings_.data(), N, value); } protected: - FixedVector mappings_; + std::array mappings_{}; }; } // namespace esphome::text_sensor From 29419d9d97557af72cc75d351b80797e9cefe83d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 13:36:08 -1000 Subject: [PATCH 078/160] [automation] Use std::array in And/Or/Xor conditions (#15282) --- esphome/automation.py | 20 +++++++++++++++----- esphome/core/base_automation.h | 25 ++++++++++++++++--------- esphome/core/helpers.h | 3 ++- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 7b1d6ceca1..94d64086ec 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -250,7 +250,9 @@ async def and_condition_to_code( args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable(condition_id, template_arg, conditions) + return cg.new_Pvariable( + condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + ) @register_condition("or", OrCondition, validate_condition_list) @@ -261,7 +263,9 @@ async def or_condition_to_code( args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable(condition_id, template_arg, conditions) + return cg.new_Pvariable( + condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + ) @register_condition("all", AndCondition, validate_condition_list) @@ -272,7 +276,9 @@ async def all_condition_to_code( args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable(condition_id, template_arg, conditions) + return cg.new_Pvariable( + condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + ) @register_condition("any", OrCondition, validate_condition_list) @@ -283,7 +289,9 @@ async def any_condition_to_code( args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable(condition_id, template_arg, conditions) + return cg.new_Pvariable( + condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + ) @register_condition("not", NotCondition, validate_potentially_and_condition) @@ -305,7 +313,9 @@ async def xor_condition_to_code( args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable(condition_id, template_arg, conditions) + return cg.new_Pvariable( + condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + ) @register_condition("lambda", LambdaCondition, cv.returning_lambda) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index efcffa8824..11133d3973 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -9,14 +9,17 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include #include #include namespace esphome { -template class AndCondition : public Condition { +template class AndCondition : public Condition { public: - explicit AndCondition(std::initializer_list *> conditions) : conditions_(conditions) {} + explicit AndCondition(std::initializer_list *> conditions) { + init_array_from(this->conditions_, conditions); + } bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (!condition->check(x...)) @@ -27,12 +30,14 @@ template class AndCondition : public Condition { } protected: - FixedVector *> conditions_; + std::array *, N> conditions_{}; }; -template class OrCondition : public Condition { +template class OrCondition : public Condition { public: - explicit OrCondition(std::initializer_list *> conditions) : conditions_(conditions) {} + explicit OrCondition(std::initializer_list *> conditions) { + init_array_from(this->conditions_, conditions); + } bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (condition->check(x...)) @@ -43,7 +48,7 @@ template class OrCondition : public Condition { } protected: - FixedVector *> conditions_; + std::array *, N> conditions_{}; }; template class NotCondition : public Condition { @@ -55,9 +60,11 @@ template class NotCondition : public Condition { Condition *condition_; }; -template class XorCondition : public Condition { +template class XorCondition : public Condition { public: - explicit XorCondition(std::initializer_list *> conditions) : conditions_(conditions) {} + explicit XorCondition(std::initializer_list *> conditions) { + init_array_from(this->conditions_, conditions); + } bool check(const Ts &...x) override { size_t result = 0; for (auto *condition : this->conditions_) { @@ -68,7 +75,7 @@ template class XorCondition : public Condition { } protected: - FixedVector *> conditions_; + std::array *, N> conditions_{}; }; template class LambdaCondition : public Condition { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 913614f564..66ba166445 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -500,7 +500,8 @@ template::max()> /// Initialize a std::array from an initializer_list. Uses memcpy for trivially copyable types (optimal codegen), /// falls back to element-wise copy for non-trivially copyable types (e.g. TemplatableValue). -/// N is set by code generation; assert catches mismatches in debug/integration tests. +/// N is always set by code generation — the caller is responsible for ensuring src.size() == N. +/// The debug assert is a safety net for development, not a runtime check. template inline void init_array_from(std::array &dest, std::initializer_list src) { #ifdef ESPHOME_DEBUG assert(src.size() == N); From 4da7f5ecc2e82e185c0dea21bf7f40caa1a88148 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 13:50:46 -1000 Subject: [PATCH 079/160] [binary_sensor] Use std::array in AutorepeatFilter (#15268) --- esphome/components/binary_sensor/__init__.py | 3 +- esphome/components/binary_sensor/filter.cpp | 27 ++++++------------ esphome/components/binary_sensor/filter.h | 29 ++++++++++++++++---- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 4705f1675d..8d072904b0 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -255,6 +255,7 @@ async def delayed_off_filter_to_code(config, filter_id): ): cv.positive_time_period_milliseconds, } ), + cv.Length(max=254), ), ) async def autorepeat_filter_to_code(config, filter_id): @@ -283,7 +284,7 @@ async def autorepeat_filter_to_code(config, filter_id): ), ) ] - var = cg.new_Pvariable(filter_id, timings) + var = cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings) await cg.register_component(var, {}) return var diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 5d525e967d..914060ce13 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -76,14 +76,11 @@ float DelayedOffFilter::get_setup_priority() const { return setup_priority::HARD optional InvertFilter::new_value(bool value) { return !value; } -AutorepeatFilter::AutorepeatFilter(std::initializer_list timings) : timings_(timings) {} - -optional AutorepeatFilter::new_value(bool value) { +// AutorepeatFilterBase +optional AutorepeatFilterBase::new_value(bool value) { if (value) { - // Ignore if already running if (this->active_timing_ != 0) return {}; - this->next_timing_(); return true; } else { @@ -94,34 +91,26 @@ optional AutorepeatFilter::new_value(bool value) { } } -void AutorepeatFilter::next_timing_() { - // Entering this method - // 1st time: starts waiting the first delay - // 2nd time: starts waiting the second delay and starts toggling with the first time_off / _on - // last time: no delay to start but have to bump the index to reflect the last - if (this->active_timing_ < this->timings_.size()) { +void AutorepeatFilterBase::next_timing_() { + if (this->active_timing_ < this->timings_count_) { this->set_timeout(AUTOREPEAT_TIMING_ID, this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); }); } - - if (this->active_timing_ <= this->timings_.size()) { + if (this->active_timing_ <= this->timings_count_) { this->active_timing_++; } - if (this->active_timing_ == 2) this->next_value_(false); - - // Leaving this method: if the toggling is started, it has to use [active_timing_ - 2] for the intervals } -void AutorepeatFilter::next_value_(bool val) { +void AutorepeatFilterBase::next_value_(bool val) { const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2]; - this->output(val); // This is at least the second one so not initial + this->output(val); this->set_timeout(AUTOREPEAT_ON_OFF_ID, val ? timing.time_on : timing.time_off, [this, val]() { this->next_value_(!val); }); } -float AutorepeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; } +float AutorepeatFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; } LambdaFilter::LambdaFilter(std::function(bool)> f) : f_(std::move(f)) {} diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 0813847ca2..37c6bf0092 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -3,6 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_BINARY_SENSOR_FILTER +#include + #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -86,22 +88,39 @@ struct AutorepeatFilterTiming { uint32_t time_on; }; -class AutorepeatFilter : public Filter, public Component { +/// Non-template base for AutorepeatFilter — all methods in filter.cpp. +/// Lambdas capture this base pointer, so set_timeout/cancel_timeout are instantiated once. +class AutorepeatFilterBase : public Filter, public Component { public: - explicit AutorepeatFilter(std::initializer_list timings); - optional new_value(bool value) override; - float get_setup_priority() const override; + AutorepeatFilterBase(const AutorepeatFilterBase &) = delete; + AutorepeatFilterBase &operator=(const AutorepeatFilterBase &) = delete; protected: + AutorepeatFilterBase() = default; void next_timing_(); void next_value_(bool val); - FixedVector timings_; + const AutorepeatFilterTiming *timings_{nullptr}; + uint8_t timings_count_{0}; uint8_t active_timing_{0}; }; +/// Template wrapper that provides inline std::array storage for timings. +/// N is set by code generation to match the exact number of timings configured in YAML. +template class AutorepeatFilter : public AutorepeatFilterBase { + public: + explicit AutorepeatFilter(std::initializer_list timings) { + init_array_from(this->timings_storage_, timings); + this->timings_ = this->timings_storage_.data(); + this->timings_count_ = N; + } + + protected: + std::array timings_storage_{}; +}; + class LambdaFilter : public Filter { public: explicit LambdaFilter(std::function(bool)> f); From 66754fa376b8495885c7718acacaf66b0872f7f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:24:32 -1000 Subject: [PATCH 080/160] [text_sensor] Use std::array in SubstituteFilter (#15266) --- esphome/components/text_sensor/__init__.py | 4 +++- esphome/components/text_sensor/filter.cpp | 20 +++++++------------- esphome/components/text_sensor/filter.h | 16 +++++++++++----- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 78a7a3a41b..5b07dd2915 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -116,7 +116,9 @@ async def substitute_filter_to_code(config, filter_id): ) for conf in config ] - return cg.new_Pvariable(filter_id, substitutions) + return cg.new_Pvariable( + filter_id, cg.TemplateArguments(len(substitutions)), substitutions + ) @FILTER_REGISTRY.register("map", MapFilter, cv.ensure_list(validate_mapping)) diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index bc044f3a73..d4e6b5b9bb 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -73,20 +73,14 @@ bool PrependFilter::new_value(std::string &value) { return true; } -// Substitute -SubstituteFilter::SubstituteFilter(const std::initializer_list &substitutions) - : substitutions_(substitutions) {} - -bool SubstituteFilter::new_value(std::string &value) { - for (const auto &sub : this->substitutions_) { - // Compute lengths once per substitution (strlen is fast, called infrequently) - const size_t from_len = strlen(sub.from); - const size_t to_len = strlen(sub.to); +// Substitute — non-template helper +bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value) { + for (size_t i = 0; i < count; i++) { + const size_t from_len = strlen(substitutions[i].from); + const size_t to_len = strlen(substitutions[i].to); std::size_t pos = 0; - while ((pos = value.find(sub.from, pos, from_len)) != std::string::npos) { - value.replace(pos, from_len, sub.to, to_len); - // Advance past the replacement to avoid infinite loop when - // the replacement contains the search pattern (e.g., f -> foo) + while ((pos = value.find(substitutions[i].from, pos, from_len)) != std::string::npos) { + value.replace(pos, from_len, substitutions[i].to, to_len); pos += to_len; } } diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 07832af9e2..6db76dcb64 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -123,14 +123,20 @@ struct Substitution { const char *to; }; -/// A simple filter that replaces a substring with another substring -class SubstituteFilter : public Filter { +/// Non-template helper (implementation in filter.cpp) +bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value); + +/// A simple filter that replaces a substring with another substring. +/// N is set by code generation to match the exact number of substitutions configured in YAML. +template class SubstituteFilter : public Filter { public: - explicit SubstituteFilter(const std::initializer_list &substitutions); - bool new_value(std::string &value) override; + explicit SubstituteFilter(const std::initializer_list &substitutions) { + init_array_from(this->substitutions_, substitutions); + } + bool new_value(std::string &value) override { return substitute_filter_apply(this->substitutions_.data(), N, value); } protected: - FixedVector substitutions_; + std::array substitutions_{}; }; /// Non-template helper (implementation in filter.cpp) From 508ec295a4d9b8b920b27ce4d3cee6818997c39d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:55:46 -1000 Subject: [PATCH 081/160] [sensor] Use std::array in OrFilter (#15262) --- esphome/components/sensor/__init__.py | 2 +- esphome/components/sensor/filter.cpp | 30 ++++++++----------------- esphome/components/sensor/filter.h | 32 ++++++++++++++++++++------- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 5569567de1..8bbaa73e2e 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -620,7 +620,7 @@ async def delta_filter_to_code(config, filter_id): @FILTER_REGISTRY.register("or", OrFilter, validate_filters) async def or_filter_to_code(config, filter_id): filters = await build_filters(config) - return cg.new_Pvariable(filter_id, filters) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), filters) @FILTER_REGISTRY.register( diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 66a9e9555b..dad09ff021 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -295,32 +295,20 @@ optional DeltaFilter::new_value(float value) { return {}; } -// OrFilter -OrFilter::OrFilter(std::initializer_list filters) : filters_(filters), phi_(this) {} -OrFilter::PhiNode::PhiNode(OrFilter *or_parent) : or_parent_(or_parent) {} - -optional OrFilter::PhiNode::new_value(float value) { - if (!this->or_parent_->has_value_) { - this->or_parent_->output(value); - this->or_parent_->has_value_ = true; +// OrFilter helpers +void or_filter_initialize(Filter **filters, size_t count, Sensor *parent, Filter *phi) { + for (size_t i = 0; i < count; i++) { + filters[i]->initialize(parent, phi); } - - return {}; + phi->initialize(parent, nullptr); } -optional OrFilter::new_value(float value) { - this->has_value_ = false; - for (auto *filter : this->filters_) - filter->input(value); +optional or_filter_new_value(Filter **filters, size_t count, float value, bool &has_value) { + has_value = false; + for (size_t i = 0; i < count; i++) + filters[i]->input(value); return {}; } -void OrFilter::initialize(Sensor *parent, Filter *next) { - Filter::initialize(parent, next); - for (auto *filter : this->filters_) { - filter->initialize(parent, &this->phi_); - } - this->phi_.initialize(parent, nullptr); -} // TimeoutFilterBase - shared loop logic void TimeoutFilterBase::loop() { diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 80fa14742c..deaaa27f19 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -489,26 +489,42 @@ class DeltaFilter : public Filter { float last_value_{NAN}; }; -class OrFilter : public Filter { +/// Non-template helpers for OrFilter (implementation in filter.cpp) +void or_filter_initialize(Filter **filters, size_t count, Sensor *parent, Filter *phi); +optional or_filter_new_value(Filter **filters, size_t count, float value, bool &has_value); + +/// N is set by code generation to match the exact number of filters configured in YAML. +template class OrFilter : public Filter { public: - explicit OrFilter(std::initializer_list filters); + explicit OrFilter(std::initializer_list filters) { init_array_from(this->filters_, filters); } - void initialize(Sensor *parent, Filter *next) override; + void initialize(Sensor *parent, Filter *next) override { + Filter::initialize(parent, next); + or_filter_initialize(this->filters_.data(), N, parent, &this->phi_); + } - optional new_value(float value) override; + optional new_value(float value) override { + return or_filter_new_value(this->filters_.data(), N, value, this->has_value_); + } protected: class PhiNode : public Filter { public: - PhiNode(OrFilter *or_parent); - optional new_value(float value) override; + PhiNode(OrFilter *or_parent) : or_parent_(or_parent) {} + optional new_value(float value) override { + if (!this->or_parent_->has_value_) { + this->or_parent_->output(value); + this->or_parent_->has_value_ = true; + } + return {}; + } protected: OrFilter *or_parent_; }; - FixedVector filters_; - PhiNode phi_; + std::array filters_{}; + PhiNode phi_{this}; bool has_value_{false}; }; From d51b047f6381c407cb8c879a96d73c4b5c36f528 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:56:04 -1000 Subject: [PATCH 082/160] [sensor] Use std::array in CalibratePolynomialFilter (#15264) --- esphome/components/sensor/__init__.py | 2 +- esphome/components/sensor/filter.cpp | 9 +++------ esphome/components/sensor/filter.h | 16 ++++++++++++---- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 8bbaa73e2e..8abba17ff9 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -808,7 +808,7 @@ async def calibrate_polynomial_filter_to_code(config, filter_id): # Column vector b = [[v] for v in y] res = [v[0] for v in _lstsq(a, b)] - return cg.new_Pvariable(filter_id, res) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(res)), res) def validate_clamp(config): diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index dad09ff021..7b7a968f48 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -396,14 +396,11 @@ optional CalibrateLinearFilter::new_value(float value) { return NAN; } -CalibratePolynomialFilter::CalibratePolynomialFilter(std::initializer_list coefficients) - : coefficients_(coefficients) {} - -optional CalibratePolynomialFilter::new_value(float value) { +optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value) { float res = 0.0f; float x = 1.0f; - for (const auto &coefficient : this->coefficients_) { - res += x * coefficient; + for (size_t i = 0; i < count; i++) { + res += x * coefficients[i]; x *= value; } return res; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index deaaa27f19..26a03acde5 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -537,13 +537,21 @@ class CalibrateLinearFilter : public Filter { FixedVector> linear_functions_; }; -class CalibratePolynomialFilter : public Filter { +/// Non-template helper for polynomial calibration (implementation in filter.cpp) +optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value); + +/// N is set by code generation to match the exact number of polynomial coefficients. +template class CalibratePolynomialFilter : public Filter { public: - explicit CalibratePolynomialFilter(std::initializer_list coefficients); - optional new_value(float value) override; + explicit CalibratePolynomialFilter(std::initializer_list coefficients) { + init_array_from(this->coefficients_, coefficients); + } + optional new_value(float value) override { + return calibrate_polynomial_compute(this->coefficients_.data(), N, value); + } protected: - FixedVector coefficients_; + std::array coefficients_{}; }; class ClampFilter : public Filter { From 17afbeb87b32c8b30a983c5926060d09ed78846d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 14:57:15 -1000 Subject: [PATCH 083/160] [binary_sensor] Use std::array in MultiClickTrigger (#15267) --- esphome/components/binary_sensor/__init__.py | 13 ++++++--- .../components/binary_sensor/automation.cpp | 18 ++++++------- esphome/components/binary_sensor/automation.h | 27 ++++++++++++++++--- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 8d072904b0..660f75ccd9 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -124,9 +124,10 @@ ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.templa DoubleClickTrigger = binary_sensor_ns.class_( "DoubleClickTrigger", automation.Trigger.template() ) -MultiClickTrigger = binary_sensor_ns.class_( - "MultiClickTrigger", automation.Trigger.template(), cg.Component +MultiClickTriggerBase = binary_sensor_ns.class_( + "MultiClickTriggerBase", automation.Trigger.template(), cg.Component ) +MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") BinarySensorPublishAction = binary_sensor_ns.class_( @@ -484,7 +485,9 @@ _BINARY_SENSOR_SCHEMA = ( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MultiClickTrigger), cv.Required(CONF_TIMING): cv.All( - [parse_multi_click_timing_str], validate_multi_click_timing + [parse_multi_click_timing_str], + validate_multi_click_timing, + cv.Length(min=1, max=255), ), cv.Optional( CONF_INVALID_COOLDOWN, default="1s" @@ -561,7 +564,9 @@ async def _build_binary_sensor_automations(var, config): ) for tim in conf[CONF_TIMING] ] - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var, timings) + trigger = cg.new_Pvariable( + conf[CONF_TRIGGER_ID], cg.TemplateArguments(len(timings)), var, timings + ) if CONF_INVALID_COOLDOWN in conf: cg.add(trigger.set_invalid_cooldown(conf[CONF_INVALID_COOLDOWN])) await cg.register_component(trigger, conf) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index 7e43d42357..eb68abce3b 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -13,7 +13,7 @@ constexpr uint32_t MULTICLICK_COOLDOWN_ID = 1; constexpr uint32_t MULTICLICK_IS_VALID_ID = 2; constexpr uint32_t MULTICLICK_IS_NOT_VALID_ID = 3; -void MultiClickTrigger::on_state_(bool state) { +void MultiClickTriggerBase::on_state_(bool state) { // Handle duplicate events if (state == this->last_state_) { return; @@ -32,7 +32,7 @@ void MultiClickTrigger::on_state_(bool state) { ESP_LOGV(TAG, "START min=%" PRIu32 " max=%" PRIu32, evt.min_length, evt.max_length); ESP_LOGV(TAG, "Multi Click: Starting multi click action!"); this->at_index_ = 1; - if (this->timing_.size() == 1 && evt.max_length == 4294967294UL) { + if (this->timing_count_ == 1 && evt.max_length == 4294967294UL) { this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); }); } else { this->schedule_is_valid_(evt.min_length); @@ -50,7 +50,7 @@ void MultiClickTrigger::on_state_(bool state) { return; } - if (*this->at_index_ == this->timing_.size()) { + if (*this->at_index_ == this->timing_count_) { this->trigger_(); return; } @@ -61,7 +61,7 @@ void MultiClickTrigger::on_state_(bool state) { ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, *this->at_index_, evt.min_length, evt.max_length); // NOLINT this->schedule_is_valid_(evt.min_length); this->schedule_is_not_valid_(evt.max_length); - } else if (*this->at_index_ + 1 != this->timing_.size()) { + } else if (*this->at_index_ + 1 != this->timing_count_) { ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->schedule_is_valid_(evt.min_length); @@ -74,7 +74,7 @@ void MultiClickTrigger::on_state_(bool state) { *this->at_index_ = *this->at_index_ + 1; } -void MultiClickTrigger::schedule_cooldown_() { +void MultiClickTriggerBase::schedule_cooldown_() { ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_); this->is_in_cooldown_ = true; this->set_timeout(MULTICLICK_COOLDOWN_ID, this->invalid_cooldown_, [this]() { @@ -86,7 +86,7 @@ void MultiClickTrigger::schedule_cooldown_() { this->cancel_timeout(MULTICLICK_IS_VALID_ID); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); } -void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { +void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) { if (min_length == 0) { this->is_valid_ = true; return; @@ -97,19 +97,19 @@ void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { this->is_valid_ = true; }); } -void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) { +void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) { this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() { ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? "RELEASE" : "PRESS"); this->is_valid_ = false; this->schedule_cooldown_(); }); } -void MultiClickTrigger::cancel() { +void MultiClickTriggerBase::cancel() { ESP_LOGV(TAG, "Multi Click: Sequence explicitly cancelled."); this->is_valid_ = false; this->schedule_cooldown_(); } -void MultiClickTrigger::trigger_() { +void MultiClickTriggerBase::trigger_() { ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!"); this->at_index_.reset(); this->cancel_timeout(MULTICLICK_TRIGGER_ID); diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index f30f9d3279..1875910aff 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -89,10 +90,10 @@ class DoubleClickTrigger : public Trigger<> { uint32_t max_length_; /// Maximum length of click. 0 means no maximum. }; -class MultiClickTrigger : public Trigger<>, public Component { +/// Non-template base for MultiClickTrigger (keeps large method bodies out of the header). +class MultiClickTriggerBase : public Trigger<>, public Component { public: - explicit MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) - : parent_(parent), timing_(timing) {} + explicit MultiClickTriggerBase(BinarySensor *parent) : parent_(parent) {} void setup() override { this->last_state_ = this->parent_->get_state_default(false); @@ -104,6 +105,8 @@ class MultiClickTrigger : public Trigger<>, public Component { void set_invalid_cooldown(uint32_t invalid_cooldown) { this->invalid_cooldown_ = invalid_cooldown; } void cancel(); + MultiClickTriggerBase(const MultiClickTriggerBase &) = delete; + MultiClickTriggerBase &operator=(const MultiClickTriggerBase &) = delete; protected: void on_state_(bool state); @@ -113,14 +116,30 @@ class MultiClickTrigger : public Trigger<>, public Component { void trigger_(); BinarySensor *parent_; - FixedVector timing_; + const MultiClickTriggerEvent *timing_{nullptr}; uint32_t invalid_cooldown_{1000}; optional at_index_{}; + uint8_t timing_count_{0}; bool last_state_{false}; bool is_in_cooldown_{false}; bool is_valid_{false}; }; +/// Template wrapper that provides inline std::array storage for timing events. +/// N is set by code generation to match the exact number of timing events configured in YAML. +template class MultiClickTrigger : public MultiClickTriggerBase { + public: + MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) + : MultiClickTriggerBase(parent) { + init_array_from(this->timing_storage_, timing); + this->timing_ = this->timing_storage_.data(); + this->timing_count_ = N; + } + + protected: + std::array timing_storage_{}; +}; + class StateTrigger : public Trigger { public: explicit StateTrigger(BinarySensor *parent) { From 18168ad7fda9cb7f93a6d5b5c183038954318076 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 15:07:15 -1000 Subject: [PATCH 084/160] [sensor] Use std::array in CalibrateLinearFilter (#15263) --- esphome/components/sensor/__init__.py | 4 +++- esphome/components/sensor/filter.cpp | 11 ++++------- esphome/components/sensor/filter.h | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 8abba17ff9..650f5ed826 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -770,7 +770,9 @@ async def calibrate_linear_filter_to_code(config, filter_id): linear_functions = [[k, b, float("NaN")]] elif config[CONF_METHOD] == "exact": linear_functions = map_linear(x, y) - return cg.new_Pvariable(filter_id, linear_functions) + return cg.new_Pvariable( + filter_id, cg.TemplateArguments(len(linear_functions)), linear_functions + ) CONF_DEGREE = "degree" diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 7b7a968f48..6a90a5af66 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -385,13 +385,10 @@ void HeartbeatFilter::setup() { float HeartbeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; } -CalibrateLinearFilter::CalibrateLinearFilter(std::initializer_list> linear_functions) - : linear_functions_(linear_functions) {} - -optional CalibrateLinearFilter::new_value(float value) { - for (const auto &f : this->linear_functions_) { - if (!std::isfinite(f[2]) || value < f[2]) - return (value * f[0]) + f[1]; +optional calibrate_linear_compute(const std::array *functions, size_t count, float value) { + for (size_t i = 0; i < count; i++) { + if (!std::isfinite(functions[i][2]) || value < functions[i][2]) + return (value * functions[i][0]) + functions[i][1]; } return NAN; } diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 26a03acde5..cb4abd154a 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -528,13 +528,21 @@ template class OrFilter : public Filter { bool has_value_{false}; }; -class CalibrateLinearFilter : public Filter { +/// Non-template helper for linear calibration (implementation in filter.cpp) +optional calibrate_linear_compute(const std::array *functions, size_t count, float value); + +/// N is set by code generation to match the exact number of calibration segments. +template class CalibrateLinearFilter : public Filter { public: - explicit CalibrateLinearFilter(std::initializer_list> linear_functions); - optional new_value(float value) override; + explicit CalibrateLinearFilter(std::initializer_list> linear_functions) { + init_array_from(this->linear_functions_, linear_functions); + } + optional new_value(float value) override { + return calibrate_linear_compute(this->linear_functions_.data(), N, value); + } protected: - FixedVector> linear_functions_; + std::array, N> linear_functions_{}; }; /// Non-template helper for polynomial calibration (implementation in filter.cpp) From ffbbe5eab33e9d18e8e209fa55b3d24de21f765f Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:55:40 +0200 Subject: [PATCH 085/160] [nextion] Fix log level for command processing limit message (#15302) --- esphome/components/nextion/nextion.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 97d9b36e4c..b0d8ba92f7 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -423,7 +423,7 @@ void Nextion::process_nextion_commands_() { DELIMITER_SIZE)) != std::string::npos) { #ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP if (++commands_processed > this->max_commands_per_loop_) { - ESP_LOGW(TAG, "Command processing limit exceeded"); + ESP_LOGV(TAG, "Command limit reached, deferring"); break; } #endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP From 95b0e6061795f6e4ba2d7474674cbbd1a07a7347 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:20:04 +0200 Subject: [PATCH 086/160] [nextion] Add accessor const qualifiers, return by ref, and deprecate `get_wave_chan_id()` (#15204) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../binary_sensor/nextion_binarysensor.h | 6 ++-- esphome/components/nextion/nextion.cpp | 30 +++++----------- .../nextion/nextion_component_base.h | 34 +++++++++---------- .../nextion/sensor/nextion_sensor.h | 5 ++- .../nextion/switch/nextion_switch.h | 2 +- .../nextion/text_sensor/nextion_textsensor.h | 2 +- 6 files changed, 32 insertions(+), 47 deletions(-) diff --git a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h index b6b23ada85..baab47851c 100644 --- a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h +++ b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h @@ -21,15 +21,15 @@ class NextionBinarySensor : public NextionComponent, void process_touch(uint8_t page_id, uint8_t component_id, bool state) override; // Set the components page id for Nextion Touch Component - void set_page_id(uint8_t page_id) { page_id_ = page_id; } + void set_page_id(uint8_t page_id) { this->page_id_ = page_id; } // Set the components component id for Nextion Touch Component - void set_component_id(uint8_t component_id) { component_id_ = component_id; } + void set_component_id(uint8_t component_id) { this->component_id_ = component_id; } void set_state(bool state) override { this->set_state(state, true, true); } void set_state(bool state, bool publish) override { this->set_state(state, publish, true); } void set_state(bool state, bool publish, bool send_to_nextion) override; - NextionQueueType get_queue_type() override { return NextionQueueType::BINARY_SENSOR; } + NextionQueueType get_queue_type() const override { return NextionQueueType::BINARY_SENSOR; } void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion) override {} void set_state_from_int(int state_value, bool publish, bool send_to_nextion) override { this->set_state(state_value != 0, publish, send_to_nextion); diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index b0d8ba92f7..22fb3ce937 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -281,7 +281,7 @@ void Nextion::print_queue_members_() { ESP_LOGN(TAG, "Queue null"); } else { ESP_LOGN(TAG, "Queue type: %d:%s, name: %s", i->component->get_queue_type(), - i->component->get_queue_type_string().c_str(), i->component->get_variable_name().c_str()); + i->component->get_queue_type_string(), i->component->get_variable_name().c_str()); } } ESP_LOGN(TAG, "*******************************************"); @@ -607,7 +607,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGE(TAG, "String return but '%s' not text sensor", component->get_variable_name().c_str()); } else { ESP_LOGN(TAG, "String resp: '%s' id: %s type: %s", to_process.c_str(), component->get_variable_name().c_str(), - component->get_queue_type_string().c_str()); + component->get_queue_type_string()); } delete nb; // NOLINT(cppcoreguidelines-owning-memory) @@ -649,7 +649,7 @@ void Nextion::process_nextion_commands_() { component->get_queue_type()); } else { ESP_LOGN(TAG, "Numeric: %s type %d:%s val %d", component->get_variable_name().c_str(), - component->get_queue_type(), component->get_queue_type_string().c_str(), value); + component->get_queue_type(), component->get_queue_type_string(), value); component->set_state_from_int(value, true, false); } @@ -842,24 +842,10 @@ void Nextion::process_nextion_commands_() { if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() && ms - this->nextion_queue_.front()->queue_time > this->max_q_age_ms_) { for (auto it = this->nextion_queue_.begin(); it != this->nextion_queue_.end();) { - NextionComponentBase *component = (*it)->component; if (ms - (*it)->queue_time > this->max_q_age_ms_) { - if ((*it)->queue_time == 0) { - ESP_LOGD(TAG, "Remove old queue '%s':'%s' (t=0)", component->get_queue_type_string().c_str(), - component->get_variable_name().c_str()); - } - - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - - if ((*it)->pending_command.empty()) { - ESP_LOGD(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string().c_str(), - component->get_variable_name().c_str()); - } else { - ESP_LOGD(TAG, "Remove old queue '%s':'%s' cmd:'%s'", component->get_queue_type_string().c_str(), - component->get_variable_name().c_str(), (*it)->pending_command.c_str()); - } + NextionComponentBase *component = (*it)->component; + ESP_LOGV(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string(), + component->get_variable_name().c_str()); if (component->get_queue_type() == NextionQueueType::NO_RESULT) { if (component->get_variable_name() == "sleep_wake") { @@ -940,7 +926,7 @@ void Nextion::all_components_send_state_(bool force_update) { binarysensortype->send_state_to_nextion(); } for (auto *sensortype : this->sensortype_) { - if ((force_update || sensortype->get_needs_to_send_update()) && sensortype->get_wave_chan_id() == 0) + if ((force_update || sensortype->get_needs_to_send_update()) && sensortype->get_wave_channel_id() == 0) sensortype->send_state_to_nextion(); } for (auto *switchtype : this->switchtype_) { @@ -1236,7 +1222,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); - ESP_LOGN(TAG, "Queue %s: %s", component->get_queue_type_string().c_str(), component->get_variable_name().c_str()); + ESP_LOGN(TAG, "Queue %s: %s", component->get_queue_type_string(), component->get_variable_name().c_str()); std::string command = "get " + component->get_variable_name_to_send(); diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index fe0692b875..4d5550d406 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -1,4 +1,6 @@ #pragma once + +#include #include #include #include "esphome/core/defines.h" @@ -35,12 +37,8 @@ class NextionComponentBase { virtual ~NextionComponentBase() = default; void set_variable_name(const std::string &variable_name, const std::string &variable_name_to_send = "") { - variable_name_ = variable_name; - if (variable_name_to_send.empty()) { - variable_name_to_send_ = variable_name; - } else { - variable_name_to_send_ = variable_name_to_send; - } + this->variable_name_ = variable_name; + this->variable_name_to_send_ = variable_name_to_send.empty() ? variable_name : variable_name_to_send; } virtual void update_component_settings(){}; @@ -64,14 +62,14 @@ class NextionComponentBase { virtual void set_state(const std::string &state, bool publish) {} virtual void set_state(const std::string &state, bool publish, bool send_to_nextion){}; - uint8_t get_component_id() { return this->component_id_; } - void set_component_id(uint8_t component_id) { component_id_ = component_id; } + uint8_t get_component_id() const { return this->component_id_; } + void set_component_id(uint8_t component_id) { this->component_id_ = component_id; } - uint8_t get_wave_channel_id() { return this->wave_chan_id_; } + uint8_t get_wave_channel_id() const { return this->wave_chan_id_; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } - std::vector get_wave_buffer() { return this->wave_buffer_; } - size_t get_wave_buffer_size() { return this->wave_buffer_.size(); } + const std::vector &get_wave_buffer() const { return this->wave_buffer_; } + size_t get_wave_buffer_size() const { return this->wave_buffer_.size(); } void clear_wave_buffer(size_t buffer_sent) { if (this->wave_buffer_.size() <= buffer_sent) { this->wave_buffer_.clear(); @@ -80,15 +78,17 @@ class NextionComponentBase { } } - std::string get_variable_name() { return this->variable_name_; } - std::string get_variable_name_to_send() { return this->variable_name_to_send_; } - virtual NextionQueueType get_queue_type() { return NextionQueueType::NO_RESULT; } - virtual std::string get_queue_type_string() { return NEXTION_QUEUE_TYPE_STRINGS[this->get_queue_type()]; } + const std::string &get_variable_name() const { return this->variable_name_; } + const std::string &get_variable_name_to_send() const { return this->variable_name_to_send_; } + virtual NextionQueueType get_queue_type() const { return NextionQueueType::NO_RESULT; } + virtual const char *get_queue_type_string() const { return NEXTION_QUEUE_TYPE_STRINGS[this->get_queue_type()]; } virtual void set_state_from_int(int state_value, bool publish, bool send_to_nextion){}; virtual void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion){}; virtual void send_state_to_nextion(){}; - bool get_needs_to_send_update() { return this->needs_to_send_update_; } - uint8_t get_wave_chan_id() { return this->wave_chan_id_; } + bool get_needs_to_send_update() const { return this->needs_to_send_update_; } + // Remove before 2026.10.0 + ESPDEPRECATED("Use get_wave_channel_id() instead. Will be removed in 2026.10.0", "2026.4.0") + uint8_t get_wave_chan_id() const { return this->get_wave_channel_id(); } void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } protected: diff --git a/esphome/components/nextion/sensor/nextion_sensor.h b/esphome/components/nextion/sensor/nextion_sensor.h index e4dde9a513..b1902f9b1b 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.h +++ b/esphome/components/nextion/sensor/nextion_sensor.h @@ -17,7 +17,7 @@ class NextionSensor : public NextionComponent, public sensor::Sensor, public Pol void update() override; void add_to_wave_buffer(float state); void set_precision(uint8_t precision) { this->precision_ = precision; } - void set_component_id(uint8_t component_id) { component_id_ = component_id; } + void set_component_id(uint8_t component_id) { this->component_id_ = component_id; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } void set_wave_max_value(uint32_t wave_maxvalue) { this->wave_maxvalue_ = wave_maxvalue; } void process_sensor(const std::string &variable_name, int state) override; @@ -27,9 +27,8 @@ class NextionSensor : public NextionComponent, public sensor::Sensor, public Pol void set_state(float state, bool publish, bool send_to_nextion) override; void set_waveform_send_last_value(bool send_last_value) { this->send_last_value_ = send_last_value; } - uint8_t get_wave_chan_id() { return this->wave_chan_id_; } void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } - NextionQueueType get_queue_type() override { + NextionQueueType get_queue_type() const override { return this->wave_chan_id_ == UINT8_MAX ? NextionQueueType::SENSOR : NextionQueueType::WAVEFORM_SENSOR; } void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion) override {} diff --git a/esphome/components/nextion/switch/nextion_switch.h b/esphome/components/nextion/switch/nextion_switch.h index 1548287473..c371ea3fc6 100644 --- a/esphome/components/nextion/switch/nextion_switch.h +++ b/esphome/components/nextion/switch/nextion_switch.h @@ -21,7 +21,7 @@ class NextionSwitch : public NextionComponent, public switch_::Switch, public Po void set_state(bool state, bool publish, bool send_to_nextion) override; void send_state_to_nextion() override { this->set_state(this->state, false, true); }; - NextionQueueType get_queue_type() override { return NextionQueueType::SWITCH; } + NextionQueueType get_queue_type() const override { return NextionQueueType::SWITCH; } void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion) override {} void set_state_from_int(int state_value, bool publish, bool send_to_nextion) override { this->set_state(state_value != 0, publish, send_to_nextion); diff --git a/esphome/components/nextion/text_sensor/nextion_textsensor.h b/esphome/components/nextion/text_sensor/nextion_textsensor.h index 5716d0a008..7c08e47189 100644 --- a/esphome/components/nextion/text_sensor/nextion_textsensor.h +++ b/esphome/components/nextion/text_sensor/nextion_textsensor.h @@ -22,7 +22,7 @@ class NextionTextSensor : public NextionComponent, public text_sensor::TextSenso void set_state(const std::string &state, bool publish, bool send_to_nextion) override; void send_state_to_nextion() override { this->set_state(this->state, false, true); }; - NextionQueueType get_queue_type() override { return NextionQueueType::TEXT_SENSOR; } + NextionQueueType get_queue_type() const override { return NextionQueueType::TEXT_SENSOR; } void set_state_from_int(int state_value, bool publish, bool send_to_nextion) override {} void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion) override { this->set_state(state_value, publish, send_to_nextion); From cd3c2ae77e0e88291d28372eb9a1a54d39e52b16 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:45:46 -1000 Subject: [PATCH 087/160] Bump aioesphomeapi from 44.8.0 to 44.8.1 (#15309) 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 c74dd265c7..0df5caf181 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.1 esphome-dashboard==20260210.0 -aioesphomeapi==44.8.0 +aioesphomeapi==44.8.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From d420e7bc236984f9e711e8f7669025f5f5eb090c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 02:57:27 -1000 Subject: [PATCH 088/160] [modbus_controller] Fix off-by-one bounds check in byte_from_hex_str (#15301) --- esphome/components/modbus_controller/modbus_controller.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 78c3b95965..693908dca4 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -81,15 +81,15 @@ inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_ inline uint8_t c_to_hex(char c) { return (c >= 'A') ? (c >= 'a') ? (c - 'a' + 10) : (c - 'A' + 10) : (c - '0'); } /** Get a byte from a hex string - * hex_byte_from_str("1122",1) returns uint_8 value 0x22 == 34 - * hex_byte_from_str("1122",0) returns 0x11 + * byte_from_hex_str("1122", 1) returns uint_8 value 0x22 == 34 + * byte_from_hex_str("1122", 0) returns 0x11 * @param value string containing hex encoding * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in * the hex string is byte_pos * 2 * @return byte value */ inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) { - if (value.length() < pos * 2 + 1) + if (value.length() < pos * 2 + 2) return 0; return (c_to_hex(value[pos * 2]) << 4) | c_to_hex(value[pos * 2 + 1]); } From 1bc6a8d95698f2913f202c11b1af13986f6c453a Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:54:09 +0200 Subject: [PATCH 089/160] [nextion] Fix queue age check using inconsistent time sources (#15317) --- esphome/components/nextion/nextion.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 22fb3ce937..bb3e12be50 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -1034,7 +1034,7 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { nextion_queue->component = new nextion::NextionComponentBase; nextion_queue->component->set_variable_name(variable_name); - nextion_queue->queue_time = millis(); + nextion_queue->queue_time = App.get_loop_component_start_time(); this->nextion_queue_.push_back(nextion_queue); From 31574a427bf721f429d6fad82f25b360ce255aad Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 30 Mar 2026 09:56:47 -0700 Subject: [PATCH 090/160] [modbus] Share helper functions across modbus components - part A (#15291) Co-authored-by: J. Nick Koston --- esphome/components/modbus/helpers.py | 83 +++++++++++++ esphome/components/modbus/modbus_helpers.h | 106 +++++++++++++++++ .../components/modbus_controller/__init__.py | 87 ++------------ .../binary_sensor/__init__.py | 2 +- .../modbus_controller/modbus_controller.cpp | 4 +- .../modbus_controller/modbus_controller.h | 109 ++++-------------- .../modbus_controller/number/__init__.py | 6 +- .../modbus_controller/output/__init__.py | 2 +- .../modbus_controller/select/__init__.py | 9 +- .../modbus_controller/sensor/__init__.py | 3 +- .../modbus_controller/switch/__init__.py | 2 +- .../modbus_controller/text_sensor/__init__.py | 2 +- 12 files changed, 231 insertions(+), 184 deletions(-) create mode 100644 esphome/components/modbus/helpers.py create mode 100644 esphome/components/modbus/modbus_helpers.h diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py new file mode 100644 index 0000000000..6f97f1e605 --- /dev/null +++ b/esphome/components/modbus/helpers.py @@ -0,0 +1,83 @@ +import esphome.codegen as cg + +modbus_ns = cg.esphome_ns.namespace("modbus") +modbus_helpers_ns = modbus_ns.namespace("helpers") + +ModbusFunctionCode_ns = modbus_ns.namespace("ModbusFunctionCode") +ModbusFunctionCode = ModbusFunctionCode_ns.enum("ModbusFunctionCode") + +MODBUS_FUNCTION_CODE = { + "read_coils": ModbusFunctionCode.READ_COILS, + "read_discrete_inputs": ModbusFunctionCode.READ_DISCRETE_INPUTS, + "read_holding_registers": ModbusFunctionCode.READ_HOLDING_REGISTERS, + "read_input_registers": ModbusFunctionCode.READ_INPUT_REGISTERS, + "write_single_coil": ModbusFunctionCode.WRITE_SINGLE_COIL, + "write_single_register": ModbusFunctionCode.WRITE_SINGLE_REGISTER, + "write_multiple_coils": ModbusFunctionCode.WRITE_MULTIPLE_COILS, + "write_multiple_registers": ModbusFunctionCode.WRITE_MULTIPLE_REGISTERS, +} + +ModbusRegisterType_ns = modbus_ns.namespace("ModbusRegisterType") +ModbusRegisterType = ModbusRegisterType_ns.enum("ModbusRegisterType") + +MODBUS_WRITE_REGISTER_TYPE = { + "custom": ModbusRegisterType.CUSTOM, + "coil": ModbusRegisterType.COIL, + "holding": ModbusRegisterType.HOLDING, +} + +MODBUS_REGISTER_TYPE = { + **MODBUS_WRITE_REGISTER_TYPE, + "discrete_input": ModbusRegisterType.DISCRETE_INPUT, + "read": ModbusRegisterType.READ, +} + +SensorValueType_ns = modbus_helpers_ns.namespace("SensorValueType") +SensorValueType = SensorValueType_ns.enum("SensorValueType") +SENSOR_VALUE_TYPE = { + "RAW": SensorValueType.RAW, + "U_WORD": SensorValueType.U_WORD, + "S_WORD": SensorValueType.S_WORD, + "U_DWORD": SensorValueType.U_DWORD, + "U_DWORD_R": SensorValueType.U_DWORD_R, + "S_DWORD": SensorValueType.S_DWORD, + "S_DWORD_R": SensorValueType.S_DWORD_R, + "U_QWORD": SensorValueType.U_QWORD, + "U_QWORD_R": SensorValueType.U_QWORD_R, + "S_QWORD": SensorValueType.S_QWORD, + "S_QWORD_R": SensorValueType.S_QWORD_R, + "FP32": SensorValueType.FP32, + "FP32_R": SensorValueType.FP32_R, +} + +TYPE_REGISTER_MAP = { + "RAW": 1, + "U_WORD": 1, + "S_WORD": 1, + "U_DWORD": 2, + "U_DWORD_R": 2, + "S_DWORD": 2, + "S_DWORD_R": 2, + "U_QWORD": 4, + "U_QWORD_R": 4, + "S_QWORD": 4, + "S_QWORD_R": 4, + "FP32": 2, + "FP32_R": 2, +} + +CPP_TYPE_REGISTER_MAP = { + "RAW": cg.uint16, + "U_WORD": cg.uint16, + "S_WORD": cg.int16, + "U_DWORD": cg.uint32, + "U_DWORD_R": cg.uint32, + "S_DWORD": cg.int32, + "S_DWORD_R": cg.int32, + "U_QWORD": cg.uint64, + "U_QWORD_R": cg.uint64, + "S_QWORD": cg.int64, + "S_QWORD_R": cg.int64, + "FP32": cg.float_, + "FP32_R": cg.float_, +} diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h new file mode 100644 index 0000000000..9f78de1c21 --- /dev/null +++ b/esphome/components/modbus/modbus_helpers.h @@ -0,0 +1,106 @@ +#pragma once + +#include + +#include "esphome/core/helpers.h" +#include "esphome/components/modbus/modbus_definitions.h" + +namespace esphome::modbus::helpers { + +enum class SensorValueType : uint8_t { + RAW = 0x00, // variable length + U_WORD = 0x1, // 1 Register unsigned + U_DWORD = 0x2, // 2 Registers unsigned + S_WORD = 0x3, // 1 Register signed + S_DWORD = 0x4, // 2 Registers signed + BIT = 0x5, + U_DWORD_R = 0x6, // 2 Registers unsigned + S_DWORD_R = 0x7, // 2 Registers unsigned + U_QWORD = 0x8, + S_QWORD = 0x9, + U_QWORD_R = 0xA, + S_QWORD_R = 0xB, + FP32 = 0xC, + FP32_R = 0xD +}; + +inline bool value_type_is_float(SensorValueType v) { + return v == SensorValueType::FP32 || v == SensorValueType::FP32_R; +} + +inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_type) { + switch (reg_type) { + case ModbusRegisterType::COIL: + return ModbusFunctionCode::READ_COILS; + case ModbusRegisterType::DISCRETE_INPUT: + return ModbusFunctionCode::READ_DISCRETE_INPUTS; + case ModbusRegisterType::HOLDING: + return ModbusFunctionCode::READ_HOLDING_REGISTERS; + case ModbusRegisterType::READ: + return ModbusFunctionCode::READ_INPUT_REGISTERS; + default: + return ModbusFunctionCode::CUSTOM; + } +} + +inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) { + switch (reg_type) { + case ModbusRegisterType::COIL: + return ModbusFunctionCode::WRITE_SINGLE_COIL; + case ModbusRegisterType::DISCRETE_INPUT: + return ModbusFunctionCode::CUSTOM; + case ModbusRegisterType::HOLDING: + return ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS; + case ModbusRegisterType::READ: + default: + return ModbusFunctionCode::CUSTOM; + } +} + +inline uint8_t c_to_hex(char c) { return (c >= 'A') ? (c >= 'a') ? (c - 'a' + 10) : (c - 'A' + 10) : (c - '0'); } + +/** Get a byte from a hex string + * byte_from_hex_str("1122", 1) returns uint_8 value 0x22 == 34 + * byte_from_hex_str("1122", 0) returns 0x11 + * @param value string containing hex encoding + * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in + * the hex string is byte_pos * 2 + * @return byte value + */ +inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) { + if (value.length() < pos * 2 + 2) + return 0; + return (c_to_hex(value[pos * 2]) << 4) | c_to_hex(value[pos * 2 + 1]); +} + +/** Get a word from a hex string + * @param value string containing hex encoding + * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in + * the hex string is byte_pos * 2 + * @return word value + */ +inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) { + return byte_from_hex_str(value, pos) << 8 | byte_from_hex_str(value, pos + 1); +} + +/** Get a dword from a hex string + * @param value string containing hex encoding + * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in + * the hex string is byte_pos * 2 + * @return dword value + */ +inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) { + return word_from_hex_str(value, pos) << 16 | word_from_hex_str(value, pos + 2); +} + +/** Get a qword from a hex string + * @param value string containing hex encoding + * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in + * the hex string is byte_pos * 2 + * @return qword value + */ +inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { + return static_cast(dword_from_hex_str(value, pos)) << 32 | dword_from_hex_str(value, pos + 4); +} + +} // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index cb0969913a..9e332425a6 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -4,6 +4,13 @@ from esphome import automation import esphome.codegen as cg from esphome.components import modbus from esphome.components.const import CONF_ENABLED +from esphome.components.modbus.helpers import ( + CPP_TYPE_REGISTER_MAP, + MODBUS_REGISTER_TYPE, + SENSOR_VALUE_TYPE, + TYPE_REGISTER_MAP, + ModbusRegisterType, +) import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET from esphome.cpp_helpers import logging @@ -41,7 +48,6 @@ CONF_SERVER_REGISTERS = "server_registers" MULTI_CONF = True modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller") -modbus_ns = cg.esphome_ns.namespace("modbus") ModbusController = modbus_controller_ns.class_( "ModbusController", cg.PollingComponent, modbus.ModbusDevice ) @@ -50,85 +56,6 @@ SensorItem = modbus_controller_ns.struct("SensorItem") ServerCourtesyResponse = modbus_controller_ns.struct("ServerCourtesyResponse") ServerRegister = modbus_controller_ns.struct("ServerRegister") -ModbusFunctionCode_ns = modbus_ns.namespace("ModbusFunctionCode") -ModbusFunctionCode = ModbusFunctionCode_ns.enum("ModbusFunctionCode") -MODBUS_FUNCTION_CODE = { - "read_coils": ModbusFunctionCode.READ_COILS, - "read_discrete_inputs": ModbusFunctionCode.READ_DISCRETE_INPUTS, - "read_holding_registers": ModbusFunctionCode.READ_HOLDING_REGISTERS, - "read_input_registers": ModbusFunctionCode.READ_INPUT_REGISTERS, - "write_single_coil": ModbusFunctionCode.WRITE_SINGLE_COIL, - "write_single_register": ModbusFunctionCode.WRITE_SINGLE_REGISTER, - "write_multiple_coils": ModbusFunctionCode.WRITE_MULTIPLE_COILS, - "write_multiple_registers": ModbusFunctionCode.WRITE_MULTIPLE_REGISTERS, -} - -ModbusRegisterType_ns = modbus_controller_ns.namespace("ModbusRegisterType") -ModbusRegisterType = ModbusRegisterType_ns.enum("ModbusRegisterType") - -MODBUS_WRITE_REGISTER_TYPE = { - "custom": ModbusRegisterType.CUSTOM, - "coil": ModbusRegisterType.COIL, - "holding": ModbusRegisterType.HOLDING, -} - -MODBUS_REGISTER_TYPE = { - **MODBUS_WRITE_REGISTER_TYPE, - "discrete_input": ModbusRegisterType.DISCRETE_INPUT, - "read": ModbusRegisterType.READ, -} - -SensorValueType_ns = modbus_controller_ns.namespace("SensorValueType") -SensorValueType = SensorValueType_ns.enum("SensorValueType") -SENSOR_VALUE_TYPE = { - "RAW": SensorValueType.RAW, - "U_WORD": SensorValueType.U_WORD, - "S_WORD": SensorValueType.S_WORD, - "U_DWORD": SensorValueType.U_DWORD, - "U_DWORD_R": SensorValueType.U_DWORD_R, - "S_DWORD": SensorValueType.S_DWORD, - "S_DWORD_R": SensorValueType.S_DWORD_R, - "U_QWORD": SensorValueType.U_QWORD, - "U_QWORD_R": SensorValueType.U_QWORD_R, - "S_QWORD": SensorValueType.S_QWORD, - "S_QWORD_R": SensorValueType.S_QWORD_R, - "FP32": SensorValueType.FP32, - "FP32_R": SensorValueType.FP32_R, -} - -TYPE_REGISTER_MAP = { - "RAW": 1, - "U_WORD": 1, - "S_WORD": 1, - "U_DWORD": 2, - "U_DWORD_R": 2, - "S_DWORD": 2, - "S_DWORD_R": 2, - "U_QWORD": 4, - "U_QWORD_R": 4, - "S_QWORD": 4, - "S_QWORD_R": 4, - "FP32": 2, - "FP32_R": 2, -} - -CPP_TYPE_REGISTER_MAP = { - "RAW": cg.uint16, - "U_WORD": cg.uint16, - "S_WORD": cg.int16, - "U_DWORD": cg.uint32, - "U_DWORD_R": cg.uint32, - "S_DWORD": cg.int32, - "S_DWORD_R": cg.int32, - "U_QWORD": cg.uint64, - "U_QWORD_R": cg.uint64, - "S_QWORD": cg.int64, - "S_QWORD_R": cg.int64, - "FP32": cg.float_, - "FP32_R": cg.float_, -} - - _LOGGER = logging.getLogger(__name__) SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( diff --git a/esphome/components/modbus_controller/binary_sensor/__init__.py b/esphome/components/modbus_controller/binary_sensor/__init__.py index 2ae008f630..18d017e13f 100644 --- a/esphome/components/modbus_controller/binary_sensor/__init__.py +++ b/esphome/components/modbus_controller/binary_sensor/__init__.py @@ -1,10 +1,10 @@ import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID from .. import ( - MODBUS_REGISTER_TYPE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index ea6ba9d085..38eaea2d1c 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -535,7 +535,7 @@ ModbusCommandItem ModbusCommandItem::create_read_command( ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; cmd.register_type = register_type; - cmd.function_code = modbus_register_read_function(register_type); + cmd.function_code = modbus::helpers::modbus_register_read_function(register_type); cmd.register_address = start_address; cmd.register_count = register_count; cmd.on_data_func = std::move(handler); @@ -548,7 +548,7 @@ ModbusCommandItem ModbusCommandItem::create_read_command(ModbusController *modbu ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; cmd.register_type = register_type; - cmd.function_code = modbus_register_read_function(register_type); + cmd.function_code = modbus::helpers::modbus_register_read_function(register_type); cmd.register_address = start_address; cmd.register_count = register_count; cmd.on_data_func = [modbusdevice](ModbusRegisterType register_type, uint16_t start_address, diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 693908dca4..438eb12c2a 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/modbus/modbus.h" +#include "esphome/components/modbus/modbus_helpers.h" #include "esphome/core/automation.h" #include @@ -19,109 +20,43 @@ class ModbusController; using modbus::ModbusFunctionCode; using modbus::ModbusRegisterType; using modbus::ModbusExceptionCode; +using modbus::helpers::SensorValueType; -enum class SensorValueType : uint8_t { - RAW = 0x00, // variable length - U_WORD = 0x1, // 1 Register unsigned - U_DWORD = 0x2, // 2 Registers unsigned - S_WORD = 0x3, // 1 Register signed - S_DWORD = 0x4, // 2 Registers signed - BIT = 0x5, - U_DWORD_R = 0x6, // 2 Registers unsigned - S_DWORD_R = 0x7, // 2 Registers unsigned - U_QWORD = 0x8, - S_QWORD = 0x9, - U_QWORD_R = 0xA, - S_QWORD_R = 0xB, - FP32 = 0xC, - FP32_R = 0xD -}; - -inline bool value_type_is_float(SensorValueType v) { - return v == SensorValueType::FP32 || v == SensorValueType::FP32_R; -} +// Remove before 2026.10.0 — these helpers have moved to modbus::helpers +ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 2026.10.0", "2026.4.0") +inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); } +ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0") inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_type) { - switch (reg_type) { - case ModbusRegisterType::COIL: - return ModbusFunctionCode::READ_COILS; - break; - case ModbusRegisterType::DISCRETE_INPUT: - return ModbusFunctionCode::READ_DISCRETE_INPUTS; - break; - case ModbusRegisterType::HOLDING: - return ModbusFunctionCode::READ_HOLDING_REGISTERS; - break; - case ModbusRegisterType::READ: - return ModbusFunctionCode::READ_INPUT_REGISTERS; - break; - default: - return ModbusFunctionCode::CUSTOM; - break; - } + return modbus::helpers::modbus_register_read_function(reg_type); } + +ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0") inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) { - switch (reg_type) { - case ModbusRegisterType::COIL: - return ModbusFunctionCode::WRITE_SINGLE_COIL; - break; - case ModbusRegisterType::DISCRETE_INPUT: - return ModbusFunctionCode::CUSTOM; - break; - case ModbusRegisterType::HOLDING: - return ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS; - break; - case ModbusRegisterType::READ: - default: - return ModbusFunctionCode::CUSTOM; - break; - } + return modbus::helpers::modbus_register_write_function(reg_type); } -inline uint8_t c_to_hex(char c) { return (c >= 'A') ? (c >= 'a') ? (c - 'a' + 10) : (c - 'A' + 10) : (c - '0'); } +ESPDEPRECATED("Use modbus::helpers::c_to_hex() instead. Removed in 2026.10.0", "2026.4.0") +inline uint8_t c_to_hex(char c) { return modbus::helpers::c_to_hex(c); } -/** Get a byte from a hex string - * byte_from_hex_str("1122", 1) returns uint_8 value 0x22 == 34 - * byte_from_hex_str("1122", 0) returns 0x11 - * @param value string containing hex encoding - * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in - * the hex string is byte_pos * 2 - * @return byte value - */ +ESPDEPRECATED("Use modbus::helpers::byte_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) { - if (value.length() < pos * 2 + 2) - return 0; - return (c_to_hex(value[pos * 2]) << 4) | c_to_hex(value[pos * 2 + 1]); + return modbus::helpers::byte_from_hex_str(value, pos); } -/** Get a word from a hex string - * @param value string containing hex encoding - * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in - * the hex string is byte_pos * 2 - * @return word value - */ +ESPDEPRECATED("Use modbus::helpers::word_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) { - return byte_from_hex_str(value, pos) << 8 | byte_from_hex_str(value, pos + 1); + return modbus::helpers::word_from_hex_str(value, pos); } -/** Get a dword from a hex string - * @param value string containing hex encoding - * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in - * the hex string is byte_pos * 2 - * @return dword value - */ +ESPDEPRECATED("Use modbus::helpers::dword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) { - return word_from_hex_str(value, pos) << 16 | word_from_hex_str(value, pos + 2); + return modbus::helpers::dword_from_hex_str(value, pos); } -/** Get a qword from a hex string - * @param value string containing hex encoding - * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in - * the hex string is byte_pos * 2 - * @return qword value - */ +ESPDEPRECATED("Use modbus::helpers::qword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { - return static_cast(dword_from_hex_str(value, pos)) << 32 | dword_from_hex_str(value, pos + 4); + return modbus::helpers::qword_from_hex_str(value, pos); } // Extract data from modbus response buffer @@ -585,7 +520,7 @@ inline float payload_to_float(const std::vector &data, const SensorItem int64_t number = payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask); float float_value; - if (value_type_is_float(item.sensor_value_type)) { + if (modbus::helpers::value_type_is_float(item.sensor_value_type)) { float_value = bit_cast(static_cast(number)); } else { float_value = static_cast(number); @@ -597,7 +532,7 @@ inline float payload_to_float(const std::vector &data, const SensorItem inline std::vector float_to_payload(float value, SensorValueType value_type) { int64_t val; - if (value_type_is_float(value_type)) { + if (modbus::helpers::value_type_is_float(value_type)) { val = bit_cast(value); } else { val = llroundf(value); diff --git a/esphome/components/modbus_controller/number/__init__.py b/esphome/components/modbus_controller/number/__init__.py index b5efd7abf0..7563adfad9 100644 --- a/esphome/components/modbus_controller/number/__init__.py +++ b/esphome/components/modbus_controller/number/__init__.py @@ -1,5 +1,9 @@ import esphome.codegen as cg from esphome.components import number +from esphome.components.modbus.helpers import ( + MODBUS_WRITE_REGISTER_TYPE, + SENSOR_VALUE_TYPE, +) import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -11,8 +15,6 @@ from esphome.const import ( ) from .. import ( - MODBUS_WRITE_REGISTER_TYPE, - SENSOR_VALUE_TYPE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index 1800a90d57..1ec4afd997 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -1,10 +1,10 @@ import esphome.codegen as cg from esphome.components import output +from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_MULTIPLY from .. import ( - SENSOR_VALUE_TYPE, ModbusItemBaseSchema, SensorItem, modbus_calc_properties, diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index c94532da51..334a4dfd76 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -1,15 +1,10 @@ import esphome.codegen as cg from esphome.components import select +from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, TYPE_REGISTER_MAP import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC -from .. import ( - SENSOR_VALUE_TYPE, - TYPE_REGISTER_MAP, - ModbusController, - SensorItem, - modbus_controller_ns, -) +from .. import ModbusController, SensorItem, modbus_controller_ns from ..const import ( CONF_FORCE_NEW_RANGE, CONF_MODBUS_CONTROLLER_ID, diff --git a/esphome/components/modbus_controller/sensor/__init__.py b/esphome/components/modbus_controller/sensor/__init__.py index d8fce54ece..5b72586c66 100644 --- a/esphome/components/modbus_controller/sensor/__init__.py +++ b/esphome/components/modbus_controller/sensor/__init__.py @@ -1,11 +1,10 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, SENSOR_VALUE_TYPE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID from .. import ( - MODBUS_REGISTER_TYPE, - SENSOR_VALUE_TYPE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index e325e6198e..a40c15ab92 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -1,10 +1,10 @@ import esphome.codegen as cg from esphome.components import switch +from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID from .. import ( - MODBUS_REGISTER_TYPE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, diff --git a/esphome/components/modbus_controller/text_sensor/__init__.py b/esphome/components/modbus_controller/text_sensor/__init__.py index 35cae645e1..995357143e 100644 --- a/esphome/components/modbus_controller/text_sensor/__init__.py +++ b/esphome/components/modbus_controller/text_sensor/__init__.py @@ -1,10 +1,10 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID from .. import ( - MODBUS_REGISTER_TYPE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, From 1a86e88373996828d5927c08ad53318e6340cb04 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 30 Mar 2026 13:15:02 -0500 Subject: [PATCH 091/160] [thermostat] Fix stale `max_runtime_exceeded` causing spurious supplemental heating/cooling (#15274) --- .../components/thermostat/thermostat_climate.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index d52a22f880..eb3e756bc2 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -606,6 +606,16 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu } void ThermostatClimate::switch_to_supplemental_action_(climate::ClimateAction action) { + // Always cancel max-runtime timers and clear exceeded flags when transitioning to idle/off, + // even if supplemental_action_ is already idle (early-return path). This prevents a stale + // heating_max_runtime_exceeded_ flag from triggering supplemental on the next heating cycle + // when HEATING_MAX_RUN_TIME fires while the main action is already IDLE. + if (action == climate::CLIMATE_ACTION_OFF || action == climate::CLIMATE_ACTION_IDLE) { + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); + this->cooling_max_runtime_exceeded_ = false; + this->heating_max_runtime_exceeded_ = false; + } // setup_complete_ helps us ensure an action is called immediately after boot if ((action == this->supplemental_action_) && this->setup_complete_) { // already in target mode @@ -975,8 +985,10 @@ void ThermostatClimate::cooling_on_timer_callback_() { void ThermostatClimate::fan_mode_timer_callback_() { ESP_LOGVV(TAG, "fan_mode timer expired"); this->switch_to_fan_mode_(this->fan_mode.value_or(climate::CLIMATE_FAN_ON)); - if (this->supports_fan_only_action_uses_fan_mode_timer_) + if (this->supports_fan_only_action_uses_fan_mode_timer_) { this->switch_to_action_(this->compute_action_()); + this->switch_to_supplemental_action_(this->compute_supplemental_action_()); + } } void ThermostatClimate::fanning_off_timer_callback_() { From ddb188e8f03d7c113c690154d9f386af188d0f4f Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 30 Mar 2026 13:15:13 -0500 Subject: [PATCH 092/160] [bme68x_bsec2] Fix warning spam, code clean-up (#15258) --- .../components/bme68x_bsec2/bme68x_bsec2.cpp | 67 +++++++++---------- .../components/bme68x_bsec2/bme68x_bsec2.h | 62 ++++++++--------- 2 files changed, 62 insertions(+), 67 deletions(-) diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index cf516f6ca6..d9e00e65b2 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -6,10 +6,7 @@ #ifdef USE_BSEC2 #include "bme68x_bsec2.h" -#include - -namespace esphome { -namespace bme68x_bsec2 { +namespace esphome::bme68x_bsec2 { #define BME68X_BSEC2_ALGORITHM_OUTPUT_LOG(a) (a == ALGORITHM_OUTPUT_CLASSIFICATION ? "Classification" : "Regression") #define BME68X_BSEC2_OPERATING_AGE_LOG(o) (o == OPERATING_AGE_4D ? "4 days" : "28 days") @@ -18,9 +15,19 @@ namespace bme68x_bsec2 { static const char *const TAG = "bme68x_bsec2.sensor"; -static const std::string IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; +static constexpr const char *const IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; + +static bool is_no_new_data_warning(int8_t status) { +#ifdef BME68X_W_NO_NEW_DATA + return status == BME68X_W_NO_NEW_DATA; +#else + return status == 2; +#endif +} void BME68xBSEC2Component::setup() { + this->warn_if_blocking_over_ = 60; // initial reads may block for up to 60ms + this->bsec_status_ = bsec_init_m(&this->bsec_instance_); if (this->bsec_status_ != BSEC_OK) { this->mark_failed(); @@ -114,7 +121,8 @@ void BME68xBSEC2Component::loop() { } else { this->status_clear_error(); } - if (this->bsec_status_ > BSEC_OK || this->bme68x_status_ > BME68X_OK) { + const bool has_bme68x_warning = this->bme68x_status_ > BME68X_OK && !is_no_new_data_warning(this->bme68x_status_); + if (this->bsec_status_ > BSEC_OK || has_bme68x_warning) { this->status_set_warning(); } else { this->status_clear_warning(); @@ -130,7 +138,7 @@ void BME68xBSEC2Component::loop() { void BME68xBSEC2Component::set_config_(const uint8_t *config, uint32_t len) { if (len > BSEC_MAX_PROPERTY_BLOB_SIZE) { - ESP_LOGE(TAG, "Configuration is larger than BSEC_MAX_PROPERTY_BLOB_SIZE"); + ESP_LOGE(TAG, "Configuration blob too large"); this->mark_failed(); return; } @@ -212,14 +220,12 @@ void BME68xBSEC2Component::run_() { if (curr_time_ns < this->bsec_settings_.next_call) { return; } - uint8_t status; - ESP_LOGV(TAG, "Performing sensor run"); struct bme68x_conf bme68x_conf; this->bsec_status_ = bsec_sensor_control_m(&this->bsec_instance_, curr_time_ns, &this->bsec_settings_); if (this->bsec_status_ < BSEC_OK) { - ESP_LOGW(TAG, "Failed to fetch sensor control settings (BSEC2 error code %d)", this->bsec_status_); + ESP_LOGW(TAG, "Fetching control settings failed (BSEC2 error code %d)", this->bsec_status_); return; } @@ -235,9 +241,9 @@ void BME68xBSEC2Component::run_() { this->bme68x_heatr_conf_.heatr_temp = this->bsec_settings_.heater_temperature; this->bme68x_heatr_conf_.heatr_dur = this->bsec_settings_.heater_duration; - // status = bme68x_set_op_mode(this->bsec_settings_.op_mode, &this->bme68x_); - status = bme68x_set_heatr_conf(BME68X_FORCED_MODE, &this->bme68x_heatr_conf_, &this->bme68x_); - status = bme68x_set_op_mode(BME68X_FORCED_MODE, &this->bme68x_); + // this->bme68x_status_ = bme68x_set_op_mode(this->bsec_settings_.op_mode, &this->bme68x_); + this->bme68x_status_ = bme68x_set_heatr_conf(BME68X_FORCED_MODE, &this->bme68x_heatr_conf_, &this->bme68x_); + this->bme68x_status_ = bme68x_set_op_mode(BME68X_FORCED_MODE, &this->bme68x_); this->op_mode_ = BME68X_FORCED_MODE; ESP_LOGV(TAG, "Using forced mode"); @@ -259,9 +265,8 @@ void BME68xBSEC2Component::run_() { BSEC_TOTAL_HEAT_DUR - (bme68x_get_meas_dur(BME68X_PARALLEL_MODE, &bme68x_conf, &this->bme68x_) / INT64_C(1000)); - status = bme68x_set_heatr_conf(BME68X_PARALLEL_MODE, &this->bme68x_heatr_conf_, &this->bme68x_); - - status = bme68x_set_op_mode(BME68X_PARALLEL_MODE, &this->bme68x_); + this->bme68x_status_ = bme68x_set_heatr_conf(BME68X_PARALLEL_MODE, &this->bme68x_heatr_conf_, &this->bme68x_); + this->bme68x_status_ = bme68x_set_op_mode(BME68X_PARALLEL_MODE, &this->bme68x_); this->op_mode_ = BME68X_PARALLEL_MODE; ESP_LOGV(TAG, "Using parallel mode"); } @@ -282,24 +287,15 @@ void BME68xBSEC2Component::run_() { this->trigger_time_ns_ = curr_time_ns; this->set_timeout("read", meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); } else { - ESP_LOGV(TAG, "Measurement not required"); - this->read_(curr_time_ns); + ESP_LOGV(TAG, "Measurement not required, queueing immediate read"); + this->trigger_time_ns_ = curr_time_ns; + this->set_timeout("read", 0, [this]() { this->read_(this->trigger_time_ns_); }); } } void BME68xBSEC2Component::read_(int64_t trigger_time_ns) { ESP_LOGV(TAG, "Reading data"); - if (this->bsec_settings_.trigger_measurement) { - uint8_t current_op_mode; - this->bme68x_status_ = bme68x_get_op_mode(¤t_op_mode, &this->bme68x_); - - if (current_op_mode == BME68X_SLEEP_MODE) { - ESP_LOGV(TAG, "Still in sleep mode, doing nothing"); - return; - } - } - if (!this->bsec_settings_.process_data) { ESP_LOGV(TAG, "Data processing not required"); return; @@ -309,12 +305,16 @@ void BME68xBSEC2Component::read_(int64_t trigger_time_ns) { uint8_t nFields = 0; this->bme68x_status_ = bme68x_get_data(this->op_mode_, &data[0], &nFields, &this->bme68x_); + if (is_no_new_data_warning(this->bme68x_status_)) { + ESP_LOGV(TAG, "BME68X did not provide new data"); + return; + } if (this->bme68x_status_ != BME68X_OK) { - ESP_LOGW(TAG, "Failed to get sensor data (BME68X error code %d)", this->bme68x_status_); + ESP_LOGW(TAG, "Fetching data failed (BME68X error code %d)", this->bme68x_status_); return; } if (nFields < 1) { - ESP_LOGD(TAG, "BME68X did not provide new data"); + ESP_LOGV(TAG, "BME68X did not provide new fields"); return; } @@ -373,7 +373,7 @@ void BME68xBSEC2Component::read_(int64_t trigger_time_ns) { uint8_t num_outputs = BSEC_NUMBER_OUTPUTS; this->bsec_status_ = bsec_do_steps_m(&this->bsec_instance_, inputs, num_inputs, outputs, &num_outputs); if (this->bsec_status_ != BSEC_OK) { - ESP_LOGW(TAG, "BSEC2 failed to process signals (BSEC2 error code %d)", this->bsec_status_); + ESP_LOGW(TAG, "Signal processing failed (BSEC2 error code %d)", this->bsec_status_); return; } if (num_outputs < 1) { @@ -474,7 +474,7 @@ void BME68xBSEC2Component::publish_sensor_(sensor::Sensor *sensor, float value, #endif #ifdef USE_TEXT_SENSOR -void BME68xBSEC2Component::publish_sensor_(text_sensor::TextSensor *sensor, const std::string &value) { +void BME68xBSEC2Component::publish_sensor_(text_sensor::TextSensor *sensor, const char *value) { if (!sensor || (sensor->has_state() && sensor->state == value)) { return; } @@ -526,6 +526,5 @@ void BME68xBSEC2Component::save_state_(uint8_t accuracy) { ESP_LOGI(TAG, "Saved state"); } -} // namespace bme68x_bsec2 -} // namespace esphome +} // namespace esphome::bme68x_bsec2 #endif diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.h b/esphome/components/bme68x_bsec2/bme68x_bsec2.h index 1ed72eee03..9317229a1f 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.h +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.h @@ -19,8 +19,7 @@ #include -namespace esphome { -namespace bme68x_bsec2 { +namespace esphome::bme68x_bsec2 { enum AlgorithmOutput { ALGORITHM_OUTPUT_IAQ, @@ -97,7 +96,7 @@ class BME68xBSEC2Component : public Component { void publish_sensor_(sensor::Sensor *sensor, float value, bool change_only = false); #endif #ifdef USE_TEXT_SENSOR - void publish_sensor_(text_sensor::TextSensor *sensor, const std::string &value); + void publish_sensor_(text_sensor::TextSensor *sensor, const char *value); #endif void load_state_(); @@ -108,39 +107,12 @@ class BME68xBSEC2Component : public Component { struct bme68x_dev bme68x_; bsec_bme_settings_t bsec_settings_; bsec_version_t version_; - uint8_t bsec_instance_[BSEC_INSTANCE_SIZE]; - struct bme68x_heatr_conf bme68x_heatr_conf_; - uint8_t op_mode_; // operating mode of sensor - bsec_library_return_t bsec_status_{BSEC_OK}; - int8_t bme68x_status_{BME68X_OK}; - - int64_t last_time_ms_{0}; - int64_t trigger_time_ns_{0}; // Stored for set_timeout lambda to help avoid heap allocation on supported 32-bit - // toolchains with small std::function SBO - uint32_t millis_overflow_counter_{0}; std::queue> queue_; + ESPPreferenceObject bsec_state_; uint8_t const *bsec2_configuration_{nullptr}; - uint32_t bsec2_configuration_length_{0}; - bool bsec2_blob_configured_{false}; - - ESPPreferenceObject bsec_state_; - uint32_t state_save_interval_ms_{21600000}; // 6 hours - 4 times a day - uint32_t last_state_save_ms_ = 0; - - float temperature_offset_{0}; - - AlgorithmOutput algorithm_output_{ALGORITHM_OUTPUT_IAQ}; - OperatingAge operating_age_{OPERATING_AGE_28D}; - Voltage voltage_{VOLTAGE_3_3V}; - - SampleRate sample_rate_{SAMPLE_RATE_LP}; // Core/gas sample rate - SampleRate temperature_sample_rate_{SAMPLE_RATE_DEFAULT}; - SampleRate pressure_sample_rate_{SAMPLE_RATE_DEFAULT}; - SampleRate humidity_sample_rate_{SAMPLE_RATE_DEFAULT}; - #ifdef USE_SENSOR sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; @@ -155,8 +127,32 @@ class BME68xBSEC2Component : public Component { #ifdef USE_TEXT_SENSOR text_sensor::TextSensor *iaq_accuracy_text_sensor_{nullptr}; #endif + + int64_t last_time_ms_{0}; + int64_t trigger_time_ns_{0}; // Stored for set_timeout lambda to help avoid heap allocation on supported 32-bit + // toolchains with small std::function SBO + + uint32_t state_save_interval_ms_{21600000}; // 6 hours - 4 times a day + uint32_t last_state_save_ms_{0}; + uint32_t millis_overflow_counter_{0}; + uint32_t bsec2_configuration_length_{0}; + bsec_library_return_t bsec_status_{BSEC_OK}; + + float temperature_offset_{0}; + + AlgorithmOutput algorithm_output_{ALGORITHM_OUTPUT_IAQ}; + OperatingAge operating_age_{OPERATING_AGE_28D}; + Voltage voltage_{VOLTAGE_3_3V}; + SampleRate sample_rate_{SAMPLE_RATE_LP}; // Core/gas sample rate + SampleRate temperature_sample_rate_{SAMPLE_RATE_DEFAULT}; + SampleRate pressure_sample_rate_{SAMPLE_RATE_DEFAULT}; + SampleRate humidity_sample_rate_{SAMPLE_RATE_DEFAULT}; + + uint8_t bsec_instance_[BSEC_INSTANCE_SIZE]; + uint8_t op_mode_; // operating mode of sensor + int8_t bme68x_status_{BME68X_OK}; + bool bsec2_blob_configured_{false}; }; -} // namespace bme68x_bsec2 -} // namespace esphome +} // namespace esphome::bme68x_bsec2 #endif From 45e6d49d36ebbb1d2d3a19d4cd6704ac2112e01a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 30 Mar 2026 13:15:27 -0500 Subject: [PATCH 093/160] [shtcx] Code clean-up (#15261) --- esphome/components/shtcx/shtcx.cpp | 18 +++++++----------- esphome/components/shtcx/shtcx.h | 16 +++++++++------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/esphome/components/shtcx/shtcx.cpp b/esphome/components/shtcx/shtcx.cpp index ec12a5babd..9ec0a2cdb7 100644 --- a/esphome/components/shtcx/shtcx.cpp +++ b/esphome/components/shtcx/shtcx.cpp @@ -2,16 +2,15 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace shtcx { +namespace esphome::shtcx { static const char *const TAG = "shtcx"; -static const uint16_t SHTCX_COMMAND_SLEEP = 0xB098; -static const uint16_t SHTCX_COMMAND_WAKEUP = 0x3517; -static const uint16_t SHTCX_COMMAND_READ_ID_REGISTER = 0xEFC8; -static const uint16_t SHTCX_COMMAND_SOFT_RESET = 0x805D; -static const uint16_t SHTCX_COMMAND_POLLING_H = 0x7866; +static constexpr uint16_t SHTCX_COMMAND_SLEEP = 0xB098; +static constexpr uint16_t SHTCX_COMMAND_WAKEUP = 0x3517; +static constexpr uint16_t SHTCX_COMMAND_READ_ID_REGISTER = 0xEFC8; +static constexpr uint16_t SHTCX_COMMAND_SOFT_RESET = 0x805D; +static constexpr uint16_t SHTCX_COMMAND_POLLING_H = 0x7866; static const LogString *shtcx_type_to_string(SHTCXType type) { switch (type) { @@ -91,8 +90,6 @@ void SHTCXComponent::update() { } else { temperature = 175.0f * float(raw_data[0]) / 65536.0f - 45.0f; humidity = 100.0f * float(raw_data[1]) / 65536.0f; - - ESP_LOGD(TAG, "Temperature=%.2f°C Humidity=%.2f%%", temperature, humidity); } if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(temperature); @@ -117,5 +114,4 @@ void SHTCXComponent::wake_up() { delayMicroseconds(200); } -} // namespace shtcx -} // namespace esphome +} // namespace esphome::shtcx diff --git a/esphome/components/shtcx/shtcx.h b/esphome/components/shtcx/shtcx.h index f9778dce8d..a86b204e2b 100644 --- a/esphome/components/shtcx/shtcx.h +++ b/esphome/components/shtcx/shtcx.h @@ -4,10 +4,13 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" -namespace esphome { -namespace shtcx { +namespace esphome::shtcx { -enum SHTCXType { SHTCX_TYPE_SHTC3 = 0, SHTCX_TYPE_SHTC1, SHTCX_TYPE_UNKNOWN }; +enum SHTCXType : uint8_t { + SHTCX_TYPE_SHTC3 = 0, + SHTCX_TYPE_SHTC1, + SHTCX_TYPE_UNKNOWN, +}; /// This class implements support for the SHT3x-DIS family of temperature+humidity i2c sensors. class SHTCXComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { @@ -23,11 +26,10 @@ class SHTCXComponent : public PollingComponent, public sensirion_common::Sensiri void wake_up(); protected: - SHTCXType type_; - uint16_t sensor_id_; sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *humidity_sensor_{nullptr}; + uint16_t sensor_id_; + SHTCXType type_; }; -} // namespace shtcx -} // namespace esphome +} // namespace esphome::shtcx From b579758c469eebbcb23fecefa3043530f493e913 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 30 Mar 2026 13:15:37 -0500 Subject: [PATCH 094/160] [dht] Code clean-up (#15271) --- esphome/components/dht/dht.cpp | 28 +++++++++++----------------- esphome/components/dht/dht.h | 13 +++++-------- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/esphome/components/dht/dht.cpp b/esphome/components/dht/dht.cpp index fef247f168..5b7b6a268f 100644 --- a/esphome/components/dht/dht.cpp +++ b/esphome/components/dht/dht.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace dht { +namespace esphome::dht { static const char *const TAG = "dht"; @@ -45,16 +44,13 @@ void DHT::update() { } if (success) { - ESP_LOGD(TAG, "Temperature %.1f°C Humidity %.1f%%", temperature, humidity); - if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(temperature); if (this->humidity_sensor_ != nullptr) this->humidity_sensor_->publish_state(humidity); this->status_clear_warning(); } else { - ESP_LOGW(TAG, "Invalid readings! Check pin number and pull-up resistor%s.", - this->is_auto_detect_ ? " and try manually specifying the model" : ""); + ESP_LOGW(TAG, "Invalid readings"); if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(NAN); if (this->humidity_sensor_ != nullptr) @@ -73,8 +69,7 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r *temperature = NAN; int error_code = 0; - int8_t i = 0; - uint8_t data[5] = {0, 0, 0, 0, 0}; + uint8_t data[5] = {}; #ifndef USE_ESP32 this->pin_.pin_mode(gpio::FLAG_OUTPUT); @@ -107,7 +102,9 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r uint8_t bit = 7; uint8_t byte = 0; - for (i = -1; i < 40; i++) { + // On 32-bit Xtensa/RISC-V cores, int8_t would require masking/sign-extension for comparisons + // vs. native int. Using int i is native word size — small win in the timing-critical section. + for (int i = -1; i < 40; i++) { uint32_t start_time = micros(); // Wait for rising edge @@ -156,11 +153,9 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r } } } - if (!report_errors && error_code != 0) - return false; - - if (error_code) { - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); + if (error_code != 0) { + if (report_errors) + ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); return false; } @@ -177,7 +172,7 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r if (checksum_a != data[4] && checksum_b != data[4]) { if (report_errors) { - ESP_LOGW(TAG, "Checksum invalid: %u!=%u", checksum_a, data[4]); + ESP_LOGW(TAG, "Invalid checksum"); } return false; } @@ -234,5 +229,4 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r return true; } -} // namespace dht -} // namespace esphome +} // namespace esphome::dht diff --git a/esphome/components/dht/dht.h b/esphome/components/dht/dht.h index 4671ee6f27..0c535f7cf6 100644 --- a/esphome/components/dht/dht.h +++ b/esphome/components/dht/dht.h @@ -4,10 +4,9 @@ #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace dht { +namespace esphome::dht { -enum DHTModel { +enum DHTModel : uint8_t { DHT_MODEL_AUTO_DETECT = 0, DHT_MODEL_DHT11, DHT_MODEL_DHT22, @@ -42,7 +41,6 @@ class DHT : public PollingComponent { this->t_pin_ = pin; this->pin_ = pin->to_isr(); } - void set_model(DHTModel model) { model_ = model; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } @@ -55,13 +53,12 @@ class DHT : public PollingComponent { protected: bool read_sensor_(float *temperature, float *humidity, bool report_errors); + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; InternalGPIOPin *t_pin_; ISRInternalGPIOPin pin_; DHTModel model_{DHT_MODEL_AUTO_DETECT}; bool is_auto_detect_{false}; - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; }; -} // namespace dht -} // namespace esphome +} // namespace esphome::dht From ad3f6ae3139b1ad8cb60bc27bb9c4ddc45336f2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 08:20:52 -1000 Subject: [PATCH 095/160] [automation] Remove actions_end_ pointer from ActionList to save RAM (#15283) --- esphome/core/automation.h | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index fc2cad99be..05c7f19588 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -419,44 +419,48 @@ template class Action { template class ActionList { public: void add_action(Action *action) { - if (this->actions_end_ == nullptr) { - this->actions_begin_ = action; - } else { - this->actions_end_->next_ = action; - } - this->actions_end_ = action; + // Walk to end of chain - action lists are short and only built during setup() + Action **tail = &this->actions_; + while (*tail != nullptr) + tail = &(*tail)->next_; + *tail = action; } void add_actions(const std::initializer_list *> &actions) { + // Find tail once, then append all actions in a single pass + Action **tail = &this->actions_; + while (*tail != nullptr) + tail = &(*tail)->next_; for (auto *action : actions) { - this->add_action(action); + *tail = action; + tail = &action->next_; } } // Force-inline: part of the Trigger→Automation→ActionList forwarding // chain collapsed to reduce automation call stack depth. inline void play(const Ts &...x) ESPHOME_ALWAYS_INLINE { - if (this->actions_begin_ != nullptr) - this->actions_begin_->play_complex(x...); + if (this->actions_ != nullptr) + this->actions_->play_complex(x...); } void play_tuple(const std::tuple &tuple) { this->play_tuple_(tuple, std::make_index_sequence{}); } void stop() { - if (this->actions_begin_ != nullptr) - this->actions_begin_->stop_complex(); + if (this->actions_ != nullptr) + this->actions_->stop_complex(); } - bool empty() const { return this->actions_begin_ == nullptr; } + bool empty() const { return this->actions_ == nullptr; } /// Check if any action in this action list is currently running. bool is_running() { - if (this->actions_begin_ == nullptr) + if (this->actions_ == nullptr) return false; - return this->actions_begin_->is_running(); + return this->actions_->is_running(); } /// Return the number of actions in this action list that are currently running. int num_running() { - if (this->actions_begin_ == nullptr) + if (this->actions_ == nullptr) return 0; - return this->actions_begin_->num_running_total(); + return this->actions_->num_running_total(); } protected: @@ -464,8 +468,7 @@ template class ActionList { this->play(std::get(tuple)...); } - Action *actions_begin_{nullptr}; - Action *actions_end_{nullptr}; + Action *actions_{nullptr}; }; template class Automation { From ffee4c22b3416d774f7cf398ffbe1b03523b168e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 08:21:58 -1000 Subject: [PATCH 096/160] [esp32_ble] Devirtualize BLE event handler dispatch (#15310) --- esphome/components/esp32_ble/__init__.py | 66 +++++++++++++++--- esphome/components/esp32_ble/ble.cpp | 21 ++---- esphome/components/esp32_ble/ble.h | 69 +++++++------------ .../components/esp32_ble_beacon/__init__.py | 1 - .../esp32_ble_beacon/esp32_ble_beacon.h | 4 +- .../components/esp32_ble_server/__init__.py | 1 - .../components/esp32_ble_server/ble_server.h | 7 +- .../components/esp32_ble_tracker/__init__.py | 2 - .../esp32_ble_tracker/esp32_ble_tracker.h | 13 ++-- esphome/core/helpers.h | 32 +++++++++ 10 files changed, 128 insertions(+), 88 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 43208eb87e..2e5e358753 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -134,10 +134,38 @@ class HandlerCounts: _handler_counts = HandlerCounts() +def _add_callback( + parent_var: cg.MockObj, + method: str, + handler_var: cg.MockObj, + params: str, + call_args: str, +) -> None: + """Generate a lambda callback that forwards to a handler method. + + Uses a braced scope with a local pointer variable so the generated C++ + lambda captures only that pointer, avoiding GCC warnings about capturing + variables with static storage duration. + """ + cg.add( + cg.RawStatement( + f"{{ auto *h = {handler_var}; " + f"{parent_var}->{method}(" + f"[h]({params}) {{ h->{call_args}; }}); }}" + ) + ) + + def register_gap_event_handler(parent_var: cg.MockObj, handler_var: cg.MockObj) -> None: """Register a GAP event handler and track the count.""" _handler_counts.gap_event += 1 - cg.add(parent_var.register_gap_event_handler(handler_var)) + _add_callback( + parent_var, + "add_gap_event_callback", + handler_var, + "esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param", + "gap_event_handler(event, param)", + ) def register_gap_scan_event_handler( @@ -145,7 +173,13 @@ def register_gap_scan_event_handler( ) -> None: """Register a GAP scan event handler and track the count.""" _handler_counts.gap_scan_event += 1 - cg.add(parent_var.register_gap_scan_event_handler(handler_var)) + _add_callback( + parent_var, + "add_gap_scan_event_callback", + handler_var, + "const esphome::esp32_ble::BLEScanResult &scan_result", + "gap_scan_event_handler(scan_result)", + ) def register_gattc_event_handler( @@ -153,7 +187,13 @@ def register_gattc_event_handler( ) -> None: """Register a GATTc event handler and track the count.""" _handler_counts.gattc_event += 1 - cg.add(parent_var.register_gattc_event_handler(handler_var)) + _add_callback( + parent_var, + "add_gattc_event_callback", + handler_var, + "esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param", + "gattc_event_handler(event, gattc_if, param)", + ) def register_gatts_event_handler( @@ -161,7 +201,13 @@ def register_gatts_event_handler( ) -> None: """Register a GATTs event handler and track the count.""" _handler_counts.gatts_event += 1 - cg.add(parent_var.register_gatts_event_handler(handler_var)) + _add_callback( + parent_var, + "add_gatts_event_callback", + handler_var, + "esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param", + "gatts_event_handler(event, gatts_if, param)", + ) def register_ble_status_event_handler( @@ -169,7 +215,13 @@ def register_ble_status_event_handler( ) -> None: """Register a BLE status event handler and track the count.""" _handler_counts.ble_status_event += 1 - cg.add(parent_var.register_ble_status_event_handler(handler_var)) + _add_callback( + parent_var, + "add_ble_status_event_callback", + handler_var, + "", + "ble_before_disabled_event_handler()", + ) def register_bt_logger(*loggers: BTLoggers) -> None: @@ -225,10 +277,6 @@ NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] esp32_ble_ns = cg.esphome_ns.namespace("esp32_ble") ESP32BLE = esp32_ble_ns.class_("ESP32BLE", cg.Component) -GAPEventHandler = esp32_ble_ns.class_("GAPEventHandler") -GATTcEventHandler = esp32_ble_ns.class_("GATTcEventHandler") -GATTsEventHandler = esp32_ble_ns.class_("GATTsEventHandler") - BLEEnabledCondition = esp32_ble_ns.class_("BLEEnabledCondition", automation.Condition) BLEEnableAction = esp32_ble_ns.class_("BLEEnableAction", automation.Action) BLEDisableAction = esp32_ble_ns.class_("BLEDisableAction", automation.Action) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 317f8fd11b..2cd2ec67f7 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -408,9 +408,7 @@ void ESP32BLE::loop() { esp_gatt_if_t gatts_if = ble_event->event_.gatts.gatts_if; esp_ble_gatts_cb_param_t *param = &ble_event->event_.gatts.gatts_param; ESP_LOGV(TAG, "gatts_event [esp_gatt_if: %d] - %d", gatts_if, event); - for (auto *gatts_handler : this->gatts_event_handlers_) { - gatts_handler->gatts_event_handler(event, gatts_if, param); - } + this->gatts_event_callbacks_.call(event, gatts_if, param); break; } #endif @@ -420,9 +418,7 @@ void ESP32BLE::loop() { esp_gatt_if_t gattc_if = ble_event->event_.gattc.gattc_if; esp_ble_gattc_cb_param_t *param = &ble_event->event_.gattc.gattc_param; ESP_LOGV(TAG, "gattc_event [esp_gatt_if: %d] - %d", gattc_if, event); - for (auto *gattc_handler : this->gattc_event_handlers_) { - gattc_handler->gattc_event_handler(event, gattc_if, param); - } + this->gattc_event_callbacks_.call(event, gattc_if, param); break; } #endif @@ -431,10 +427,7 @@ void ESP32BLE::loop() { switch (gap_event) { case ESP_GAP_BLE_SCAN_RESULT_EVT: #ifdef ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT - // Use the new scan event handler - no memcpy! - for (auto *scan_handler : this->gap_scan_event_handlers_) { - scan_handler->gap_scan_event_handler(ble_event->scan_result()); - } + this->gap_scan_event_callbacks_.call(ble_event->scan_result()); #endif break; @@ -478,9 +471,7 @@ void ESP32BLE::loop() { } // clang-format on // Dispatch to all registered handlers - for (auto *gap_handler : this->gap_event_handlers_) { - gap_handler->gap_event_handler(gap_event, param); - } + this->gap_event_callbacks_.call(gap_event, param); } #endif break; @@ -518,9 +509,7 @@ void ESP32BLE::loop_handle_state_transition_not_active_() { ESP_LOGD(TAG, "Disabling"); #ifdef ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT - for (auto *ble_event_handler : this->ble_status_event_handlers_) { - ble_event_handler->ble_before_disabled_event_handler(); - } + this->ble_status_event_callbacks_.call(); #endif if (!ble_dismantle_()) { diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 82b2789461..de8c8c2343 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -87,37 +87,6 @@ enum BLEComponentState : uint8_t { BLE_COMPONENT_STATE_ACTIVE, }; -class GAPEventHandler { - public: - virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; -}; - -class GAPScanEventHandler { - public: - virtual void gap_scan_event_handler(const BLEScanResult &scan_result) = 0; -}; - -#ifdef USE_ESP32_BLE_CLIENT -class GATTcEventHandler { - public: - virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) = 0; -}; -#endif - -#ifdef USE_ESP32_BLE_SERVER -class GATTsEventHandler { - public: - virtual void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, - esp_ble_gatts_cb_param_t *param) = 0; -}; -#endif - -class BLEStatusEventHandler { - public: - virtual void ble_before_disabled_event_handler() = 0; -}; - class ESP32BLE : public Component { public: void set_io_capability(IoCapability io_capability) { this->io_cap_ = (esp_ble_io_cap_t) io_capability; } @@ -154,22 +123,28 @@ class ESP32BLE : public Component { #endif #ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT - void register_gap_event_handler(GAPEventHandler *handler) { this->gap_event_handlers_.push_back(handler); } + template void add_gap_event_callback(F &&callback) { + this->gap_event_callbacks_.add(std::forward(callback)); + } #endif #ifdef ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT - void register_gap_scan_event_handler(GAPScanEventHandler *handler) { - this->gap_scan_event_handlers_.push_back(handler); + template void add_gap_scan_event_callback(F &&callback) { + this->gap_scan_event_callbacks_.add(std::forward(callback)); } #endif #if defined(USE_ESP32_BLE_CLIENT) && defined(ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT) - void register_gattc_event_handler(GATTcEventHandler *handler) { this->gattc_event_handlers_.push_back(handler); } + template void add_gattc_event_callback(F &&callback) { + this->gattc_event_callbacks_.add(std::forward(callback)); + } #endif #if defined(USE_ESP32_BLE_SERVER) && defined(ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT) - void register_gatts_event_handler(GATTsEventHandler *handler) { this->gatts_event_handlers_.push_back(handler); } + template void add_gatts_event_callback(F &&callback) { + this->gatts_event_callbacks_.add(std::forward(callback)); + } #endif #ifdef ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT - void register_ble_status_event_handler(BLEStatusEventHandler *handler) { - this->ble_status_event_handlers_.push_back(handler); + template void add_ble_status_event_callback(F &&callback) { + this->ble_status_event_callbacks_.add(std::forward(callback)); } #endif void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } @@ -202,21 +177,27 @@ class ESP32BLE : public Component { private: template friend void enqueue_ble_event(Args... args); - // Handler vectors - use StaticVector when counts are known at compile time #ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT - StaticVector gap_event_handlers_; + StaticCallbackManager + gap_event_callbacks_; #endif #ifdef ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT - StaticVector gap_scan_event_handlers_; + StaticCallbackManager + gap_scan_event_callbacks_; #endif #if defined(USE_ESP32_BLE_CLIENT) && defined(ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT) - StaticVector gattc_event_handlers_; + StaticCallbackManager + gattc_event_callbacks_; #endif #if defined(USE_ESP32_BLE_SERVER) && defined(ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT) - StaticVector gatts_event_handlers_; + StaticCallbackManager + gatts_event_callbacks_; #endif #ifdef ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT - StaticVector ble_status_event_handlers_; + StaticCallbackManager ble_status_event_callbacks_; #endif // Large objects (size depends on template parameters, but typically aligned to 4 bytes) diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 04c783980d..e2e790164e 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -13,7 +13,6 @@ esp32_ble_beacon_ns = cg.esphome_ns.namespace("esp32_ble_beacon") ESP32BLEBeacon = esp32_ble_beacon_ns.class_( "ESP32BLEBeacon", cg.Component, - esp32_ble.GAPEventHandler, cg.Parented.template(esp32_ble.ESP32BLE), ) CONF_MAJOR = "major" diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index 7a0424f3aa..e16c413179 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -35,7 +35,7 @@ using esp_ble_ibeacon_t = struct { using namespace esp32_ble; -class ESP32BLEBeacon : public Component, public GAPEventHandler, public Parented { +class ESP32BLEBeacon : public Component, public Parented { public: explicit ESP32BLEBeacon(const std::array &uuid) : uuid_(uuid) {} @@ -51,7 +51,7 @@ class ESP32BLEBeacon : public Component, public GAPEventHandler, public Parented #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID void set_tx_power(esp_power_level_t val) { this->tx_power_ = val; } #endif - void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); protected: void on_advertise_(); diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 827ddba955..57106cd93b 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -72,7 +72,6 @@ BLECharacteristic_ns = esp32_ble_server_ns.namespace("BLECharacteristic") BLEServer = esp32_ble_server_ns.class_( "BLEServer", cg.Component, - esp32_ble.GATTsEventHandler, cg.Parented.template(esp32_ble.ESP32BLE), ) esp32_ble_server_automations_ns = esp32_ble_server_ns.namespace( diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 1b419d2ee4..9708ed40c8 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -24,7 +24,7 @@ namespace esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; -class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEventHandler, public Parented { +class BLEServer : public Component, public Parented { public: void setup() override; void loop() override; @@ -53,10 +53,9 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv const uint16_t *get_clients() const { return this->clients_; } uint8_t get_client_count() const { return this->client_count_; } - void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, - esp_ble_gatts_cb_param_t *param) override; + void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param); - void ble_before_disabled_event_handler() override; + void ble_before_disabled_event_handler(); // Direct callback registration - supports multiple callbacks void on_connect(std::function &&callback) { diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index c5e8f3178d..b9c4c28ccf 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -90,8 +90,6 @@ esp32_ble_tracker_ns = cg.esphome_ns.namespace("esp32_ble_tracker") ESP32BLETracker = esp32_ble_tracker_ns.class_( "ESP32BLETracker", cg.Component, - esp32_ble.GAPEventHandler, - esp32_ble.GATTcEventHandler, cg.Parented.template(esp32_ble.ESP32BLE), ) ESPBTClient = esp32_ble_tracker_ns.class_("ESPBTClient") diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index f50ed107b6..ff69a4dcd2 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -291,10 +291,6 @@ class ESPBTClient : public ESPBTDeviceListener { }; class ESP32BLETracker : public Component, - public GAPEventHandler, - public GAPScanEventHandler, - public GATTcEventHandler, - public BLEStatusEventHandler, #ifdef USE_OTA_STATE_LISTENER public ota::OTAGlobalStateListener, #endif @@ -325,11 +321,10 @@ class ESP32BLETracker : public Component, void start_scan(); void stop_scan(); - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override; - void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; - void gap_scan_event_handler(const BLEScanResult &scan_result) override; - void ble_before_disabled_event_handler() override; + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param); + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); + void gap_scan_event_handler(const BLEScanResult &scan_result); + void ble_before_disabled_event_handler(); #ifdef USE_OTA_STATE_LISTENER void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 66ba166445..f96b888e28 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1830,6 +1830,38 @@ template class CallbackManager { std::vector> callbacks_; }; +/** CallbackManager backed by StaticVector for compile-time-known callback counts. + * + * Drop-in replacement for CallbackManager that avoids std::vector template bloat + * (_M_realloc_insert, etc.) when the maximum number of callbacks is known at compile time. + * + * @tparam N Maximum number of callbacks (compile-time constant, typically from cg.add_define()) + * @tparam Ts The arguments for the callbacks, wrapped in void(). + */ +template class StaticCallbackManager; + +template class StaticCallbackManager { + public: + /// Add any callable. Small trivially-copyable callables (like [this] lambdas) + /// are stored inline without heap allocation. + template void add(F &&callback) { this->add_(Callback::create(std::forward(callback))); } + + /// Call all callbacks in this manager. + void call(Ts... args) { + for (auto &cb : this->callbacks_) + cb.call(args...); + } + size_t size() const { return this->callbacks_.size(); } + + /// Call all callbacks in this manager. + void operator()(Ts... args) { call(args...); } + + protected: + /// Non-template core to avoid code duplication per lambda type. + void add_(Callback cb) { this->callbacks_.push_back(cb); } + StaticVector, N> callbacks_; +}; + template class LazyCallbackManager; /** Lazy-allocating callback manager that only allocates memory when callbacks are registered. From 8969eb76e9bdd12734f96af0200c89a18b70bc75 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 08:24:17 -1000 Subject: [PATCH 097/160] [wifi] Avoid redundant SDK calls in WiFi loop on ESP8266 (#15303) --- esphome/components/wifi/wifi_component.cpp | 8 +--- esphome/components/wifi/wifi_component.h | 13 +++++- .../wifi/wifi_component_esp8266.cpp | 44 ++++++++++--------- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index db20332667..7b31a22ed5 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -784,7 +784,8 @@ void WiFiComponent::loop() { } case WIFI_COMPONENT_STATE_STA_CONNECTED: { - if (!this->is_connected_()) { + // Use cached connected_ set unconditionally at the top of loop() + if (!this->connected_) { ESP_LOGW(TAG, "Connection lost; reconnecting"); this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; this->retry_connect(); @@ -2129,11 +2130,6 @@ void WiFiComponent::retry_connect() { } void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } -bool WiFiComponent::is_connected_() const { - return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && - this->wifi_sta_connect_status_() == WiFiSTAConnectStatus::CONNECTED && !this->error_from_callback_; -} -void WiFiComponent::update_connected_state_() { this->connected_ = this->is_connected_(); } void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { this->power_save_ = power_save; #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 8dfe5fa7af..073341fe79 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -670,8 +670,11 @@ class WiFiComponent final : public Component { bool wifi_sta_connect_(const WiFiAP &ap); void wifi_pre_setup_(); WiFiSTAConnectStatus wifi_sta_connect_status_() const; - bool is_connected_() const; - void update_connected_state_(); + bool is_connected_() const { + return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && + this->wifi_sta_connect_status_() == WiFiSTAConnectStatus::CONNECTED && !this->error_from_callback_; + } + void update_connected_state_() { this->connected_ = this->is_connected_(); } bool wifi_scan_start_(bool passive); #ifdef USE_WIFI_AP @@ -811,6 +814,12 @@ class WiFiComponent final : public Component { uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ bool error_from_callback_{false}; +#ifdef USE_ESP8266 + // ESP8266WiFiSTAState enum, defined in wifi_component_esp8266.cpp. + // Written from SDK system context (wifi_event_callback) — uint8_t writes + // are atomic on Xtensa LX106 so no synchronization is needed. + uint8_t sta_state_{0}; +#endif RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY}; RoamingState roaming_state_{RoamingState::IDLE}; bssid_t roaming_target_bssid_{}; // BSSID of the AP we're trying to roam to diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 03800cc3a9..cb53d3ac1b 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -44,11 +44,14 @@ namespace esphome::wifi { static const char *const TAG = "wifi_esp8266"; -static bool s_sta_connected = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_got_ip = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_connect_not_found = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +enum class ESP8266WiFiSTAState : uint8_t { + IDLE, // Not connecting + CONNECTING, // Connection in progress + ASSOCIATED, // Associated to AP, waiting for IP + CONNECTED, // Successfully connected with IP + ERROR_NOT_FOUND, // AP not found (probe failed) + ERROR_FAILED, // Connection failed (auth, timeout, etc.) +}; bool WiFiComponent::wifi_mode_(optional sta, optional ap) { uint8_t current_mode = wifi_get_opmode(); @@ -359,11 +362,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // Reset flags, do this _before_ wifi_station_connect as the callback method // may be called from wifi_station_connect - s_sta_connecting = true; - s_sta_connected = false; - s_sta_got_ip = false; - s_sta_connect_error = false; - s_sta_connect_not_found = false; + this->sta_state_ = static_cast(ESP8266WiFiSTAState::CONNECTING); ETS_UART_INTR_DISABLE(); ret = wifi_station_connect(); @@ -493,7 +492,7 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { ESP_LOGV(TAG, "Connected ssid='%.*s' bssid=%s channel=%u", it.ssid_len, (const char *) it.ssid, bssid_buf, it.channel); #endif - s_sta_connected = true; + global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ASSOCIATED); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // Defer listener notification until state machine reaches STA_CONNECTED // This ensures wifi.connected condition returns true in listener automations @@ -506,16 +505,14 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { if (it.reason == REASON_NO_AP_FOUND) { ESP_LOGW(TAG, "Disconnected ssid='%.*s' reason='Probe Request Unsuccessful'", it.ssid_len, (const char *) it.ssid); - s_sta_connect_not_found = true; + global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_NOT_FOUND); } else { char bssid_s[18]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, LOG_STR_ARG(get_disconnect_reason_str(it.reason))); - s_sta_connect_error = true; + global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); } - s_sta_connected = false; - s_sta_connecting = false; global_wifi_component->error_from_callback_ = true; #ifdef USE_WIFI_CONNECT_STATE_LISTENERS global_wifi_component->pending_.disconnect = true; @@ -541,7 +538,7 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { mask_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGV(TAG, "static_ip=%s gateway=%s netmask=%s", network::IPAddress(&it.ip).str_to(ip_buf), network::IPAddress(&it.gw).str_to(gw_buf), network::IPAddress(&it.mask).str_to(mask_buf)); - s_sta_got_ip = true; + global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::CONNECTED); #ifdef USE_WIFI_IP_STATE_LISTENERS // Defer listener callbacks to main loop - system context has limited stack global_wifi_component->pending_.got_ip = true; @@ -636,17 +633,22 @@ void WiFiComponent::wifi_pre_setup_() { } WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { - station_status_t status = wifi_station_get_connect_status(); - if (status == STATION_GOT_IP) + // Use cached state from wifi_event_callback() instead of calling + // wifi_station_get_connect_status() which queries the SDK every time. + // Use if statements with early returns instead of switch to avoid GCC + // generating a CSWTCH lookup table in .rodata (flash) on ESP8266. + auto state = static_cast(this->sta_state_); + if (state == ESP8266WiFiSTAState::CONNECTED) return WiFiSTAConnectStatus::CONNECTED; - if (status == STATION_NO_AP_FOUND) + if (state == ESP8266WiFiSTAState::ERROR_NOT_FOUND) return WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND; - if (status == STATION_CONNECT_FAIL || status == STATION_WRONG_PASSWORD) + if (state == ESP8266WiFiSTAState::ERROR_FAILED) return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED; - if (status == STATION_CONNECTING) + if (state == ESP8266WiFiSTAState::CONNECTING || state == ESP8266WiFiSTAState::ASSOCIATED) return WiFiSTAConnectStatus::CONNECTING; return WiFiSTAConnectStatus::IDLE; } + bool WiFiComponent::wifi_scan_start_(bool passive) { // enable STA if (!this->wifi_mode_(true, {})) From 46ea61666e97061d32a115b29a38328553067e33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 08:24:34 -1000 Subject: [PATCH 098/160] [wifi] Replace FreeRTOS queue with LockFreeQueue on ESP-IDF (#15306) --- esphome/components/wifi/wifi_component.h | 11 ++++++++ .../wifi/wifi_component_esp_idf.cpp | 26 ++++++------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 073341fe79..9a08902d47 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -6,6 +6,9 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#ifdef USE_ESP32 +#include "esphome/core/lock_free_queue.h" +#endif #include "esphome/core/string_ref.h" #include @@ -727,6 +730,7 @@ class WiFiComponent final : public Component { #ifdef USE_ESP32 void wifi_process_event_(IDFWiFiEvent *data); + friend void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #endif #ifdef USE_RP2040 @@ -871,6 +875,13 @@ class WiFiComponent final : public Component { bool is_high_performance_mode_{false}; #endif +#ifdef USE_ESP32 + // Lock-free SPSC queue for WiFi events from ESP-IDF event handler. + // 17 slots = 16 usable (ring buffer reserves one slot). WiFi events are rare. + // Placed at end of class to avoid padding between smaller fields. + LockFreeQueue event_queue_; +#endif + private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index d8b3db9667..4097df80af 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -47,7 +47,6 @@ namespace esphome::wifi { static const char *const TAG = "wifi_esp32"; static EventGroupHandle_t s_wifi_event_group; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static QueueHandle_t s_event_queue; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static esp_netif_t *s_sta_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #ifdef USE_WIFI_AP static esp_netif_t *s_ap_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -132,11 +131,10 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi return; } - // copy to heap to keep queue object small + // copy to heap — WiFi events are rare so heap alloc is fine auto *to_send = new IDFWiFiEvent; // NOLINT(cppcoreguidelines-owning-memory) memcpy(to_send, &event, sizeof(IDFWiFiEvent)); - // don't block, we may miss events but the core can handle that - if (xQueueSend(s_event_queue, &to_send, 0L) != pdPASS) { + if (!global_wifi_component->event_queue_.push(to_send)) { delete to_send; // NOLINT(cppcoreguidelines-owning-memory) } } @@ -157,12 +155,6 @@ void WiFiComponent::wifi_pre_setup_() { ESP_LOGE(TAG, "xEventGroupCreate failed"); return; } - // NOLINTNEXTLINE(bugprone-sizeof-expression) - s_event_queue = xQueueCreate(64, sizeof(IDFWiFiEvent *)); - if (s_event_queue == nullptr) { - ESP_LOGE(TAG, "xQueueCreate failed"); - return; - } err = esp_event_loop_create_default(); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_event_loop_create_default failed: %s", esp_err_to_name(err)); @@ -724,16 +716,14 @@ const char *get_disconnect_reason_str(uint8_t reason) { } void WiFiComponent::wifi_loop_() { - while (true) { - IDFWiFiEvent *data; - if (xQueueReceive(s_event_queue, &data, 0L) != pdTRUE) { - // no event ready - break; - } + uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); + if (dropped > 0) { + ESP_LOGW(TAG, "Dropped %u WiFi events due to buffer overflow", dropped); + } - // process event + IDFWiFiEvent *data; + while ((data = this->event_queue_.pop()) != nullptr) { wifi_process_event_(data); - delete data; // NOLINT(cppcoreguidelines-owning-memory) } } From 8688ef7125cd7eac012ec26e90b0e4c63e67ebb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 08:24:48 -1000 Subject: [PATCH 099/160] [benchmark] Fix decode benchmarks being optimized away by compiler (#15293) --- .../components/api/bench_proto_decode.cpp | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/tests/benchmarks/components/api/bench_proto_decode.cpp b/tests/benchmarks/components/api/bench_proto_decode.cpp index 113201dd8a..961c629f2a 100644 --- a/tests/benchmarks/components/api/bench_proto_decode.cpp +++ b/tests/benchmarks/components/api/bench_proto_decode.cpp @@ -10,10 +10,9 @@ namespace esphome::api::benchmarks { // sub-microsecond benchmarks. static constexpr int kInnerIterations = 2000; -// Helper: encode a message into a buffer and return it. -// Benchmarks encode once in setup, then decode the resulting bytes in a loop. -// This keeps decode benchmarks in sync with the actual protobuf schema — -// hand-encoded byte arrays would silently break when fields change. +// Helper: encode a message into an APIBuffer for reuse in decode benchmarks. +// Optimization barriers are applied to the decode target objects via +// DoNotOptimize/ClobberMemory, not to this buffer. template static APIBuffer encode_message(const T &msg) { APIBuffer buffer; uint32_t size = msg.calculate_size(); @@ -23,6 +22,12 @@ template static APIBuffer encode_message(const T &msg) { return buffer; } +/// Force a pointer through an asm barrier so the compiler cannot +/// prove its contents are unchanged across iterations. +/// benchmark::DoNotOptimize/ClobberMemory are insufficient under +/// CodSpeed's valgrind-based instrumentation. +static void escape(void *p) { asm volatile("" : : "g"(p) : "memory"); } + // --- HelloRequest decode (string + varint fields) --- static void Decode_HelloRequest(benchmark::State &state) { @@ -31,13 +36,18 @@ static void Decode_HelloRequest(benchmark::State &state) { source.api_version_major = 1; source.api_version_minor = 10; auto encoded = encode_message(source); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); for (auto _ : state) { - HelloRequest msg; for (int i = 0; i < kInnerIterations; i++) { - msg.decode(encoded.data(), encoded.size()); + HelloRequest msg; + escape(&msg); + msg.decode(data, size); + escape(&msg); } - benchmark::DoNotOptimize(msg.api_version_major); } state.SetItemsProcessed(state.iterations() * kInnerIterations); } @@ -50,13 +60,18 @@ static void Decode_SwitchCommandRequest(benchmark::State &state) { source.key = 0x12345678; source.state = true; auto encoded = encode_message(source); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); for (auto _ : state) { - SwitchCommandRequest msg; for (int i = 0; i < kInnerIterations; i++) { - msg.decode(encoded.data(), encoded.size()); + SwitchCommandRequest msg; + escape(&msg); + msg.decode(data, size); + escape(&msg); } - benchmark::DoNotOptimize(msg.state); } state.SetItemsProcessed(state.iterations() * kInnerIterations); } @@ -78,13 +93,18 @@ static void Decode_LightCommandRequest(benchmark::State &state) { source.has_effect = true; source.effect = StringRef::from_lit("rainbow"); auto encoded = encode_message(source); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); for (auto _ : state) { - LightCommandRequest msg; for (int i = 0; i < kInnerIterations; i++) { - msg.decode(encoded.data(), encoded.size()); + LightCommandRequest msg; + escape(&msg); + msg.decode(data, size); + escape(&msg); } - benchmark::DoNotOptimize(msg.brightness); } state.SetItemsProcessed(state.iterations() * kInnerIterations); } From 8561a8c495afdf7caaffb3e043c644dbed7474e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 08:48:04 -1000 Subject: [PATCH 100/160] [core] Suppress component source overflow warnings in testing mode (#15320) --- esphome/cpp_helpers.py | 12 +++++++----- tests/unit_tests/test_cpp_helpers.py | 21 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index e7ff2965c8..479090016f 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -71,11 +71,13 @@ def register_component_source(name: str) -> int: return pool.sources[name] idx = len(pool.sources) + 1 if idx > _MAX_COMPONENT_SOURCES: - _LOGGER.warning( - "Too many unique component source names (max %d), '%s' will show as ''", - _MAX_COMPONENT_SOURCES, - name, - ) + if not CORE.testing_mode: + _LOGGER.warning( + "Too many unique component source names (max %d), " + "'%s' will show as ''", + _MAX_COMPONENT_SOURCES, + name, + ) return 0 pool.sources[name] = idx _ensure_source_table_registered() diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 52424a7cb2..a76ea21c23 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -140,9 +140,28 @@ def test_register_component_source_overflow_warns( sources={f"comp_{i}": i + 1 for i in range(0xFF)}, table_registered=True, ) - monkeypatch.setattr(ch, "CORE", Mock(data={ch._COMPONENT_SOURCE_DOMAIN: pool})) + monkeypatch.setattr( + ch, "CORE", Mock(data={ch._COMPONENT_SOURCE_DOMAIN: pool}, testing_mode=False) + ) with caplog.at_level(logging.WARNING): idx = register_component_source("overflow_component") assert idx == 0 assert "Too many unique component source names" in caplog.text assert "overflow_component" in caplog.text + + +def test_register_component_source_overflow_suppressed_in_testing_mode( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + # Pre-fill pool to max + pool = ComponentSourcePool( + sources={f"comp_{i}": i + 1 for i in range(0xFF)}, + table_registered=True, + ) + monkeypatch.setattr( + ch, "CORE", Mock(data={ch._COMPONENT_SOURCE_DOMAIN: pool}, testing_mode=True) + ) + with caplog.at_level(logging.WARNING): + idx = register_component_source("overflow_component") + assert idx == 0 + assert "Too many unique component source names" not in caplog.text From f25fa7123599fe3a33fff4c652486b6fe6c23e47 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 31 Mar 2026 06:25:15 +1000 Subject: [PATCH 101/160] [lvgl] Fix align_to directives (#15311) --- esphome/components/lvgl/__init__.py | 15 +++++++++++++-- esphome/components/lvgl/defines.py | 1 + esphome/components/lvgl/lvgl_esphome.h | 15 ++++++++++++--- esphome/components/lvgl/trigger.py | 25 +++++++++++++++++++++++-- esphome/components/lvgl/types.py | 4 ++-- tests/components/lvgl/lvgl-package.yaml | 4 +++- 6 files changed, 54 insertions(+), 10 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index a6afa12afa..6377183ef4 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -48,6 +48,7 @@ from esphome.yaml_util import load_yaml from . import defines as df, helpers, lv_validation as lvalid, widgets from .automation import focused_widgets, layers_to_code, lvgl_update, refreshed_widgets +from .defines import CONF_ALIGN_TO_LAMBDA_ID from .encoders import ( ENCODERS_CONFIG, encoders_to_code, @@ -69,8 +70,16 @@ from .schemas import ( ) from .styles import styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code -from .trigger import add_on_boot_triggers, generate_triggers -from .types import IdleTrigger, PlainTrigger, lv_font_t, lv_group_t, lv_style_t, lvgl_ns +from .trigger import add_on_boot_triggers, generate_align_tos, generate_triggers +from .types import ( + IdleTrigger, + PlainTrigger, + lv_font_t, + lv_group_t, + lv_lambda_t, + lv_style_t, + lvgl_ns, +) from .widgets import ( LvScrActType, Widget, @@ -345,6 +354,7 @@ async def to_code(configs): Widget.widgets_completed = True async with LvContext(): await generate_triggers() + await generate_align_tos(configs[0]) for config in configs: lv_component = await cg.get_variable(config[CONF_ID]) await generate_page_triggers(config) @@ -458,6 +468,7 @@ LVGL_SCHEMA = cv.All( .extend( { cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), + cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), cv.GenerateID(df.CONF_DISPLAYS): display_schema, cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), cv.Optional( diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 0a53d88669..72345ca98e 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -504,6 +504,7 @@ CONF_ACCEPTED_CHARS = "accepted_chars" CONF_ADJUSTABLE = "adjustable" CONF_ALIGN = "align" CONF_ALIGN_TO = "align_to" +CONF_ALIGN_TO_LAMBDA_ID = "align_to_lambda_id" CONF_ANGLE_RANGE = "angle_range" CONF_ANIMATED = "animated" CONF_ANIMATION = "animation" diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 8de82d50c0..21d1e0d417 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -128,10 +128,19 @@ class LvPageType : public Parented { bool skip; }; -using LvLambdaType = std::function; -using set_value_lambda_t = std::function; using event_callback_t = void(lv_event_t *); -using text_lambda_t = std::function; + +class LvLambdaComponent : public Component { + public: + LvLambdaComponent(void (*callback)()) : callback_(callback) {} + + void setup() override { this->callback_(); } + // execute after the LvglComponent is setup + float get_setup_priority() const override { return setup_priority::PROCESSOR - 5; } + + protected: + void (*callback_)(); +}; template class ObjUpdateAction : public Action { public: diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index 077ff06bb7..54309cdf89 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -8,10 +8,13 @@ from esphome.const import ( CONF_X, CONF_Y, ) +from esphome.cpp_generator import new_Pvariable +from esphome.cpp_helpers import register_component from .defines import ( CONF_ALIGN, CONF_ALIGN_TO, + CONF_ALIGN_TO_LAMBDA_ID, DIRECTIONS, LV_EVENT_MAP, LV_EVENT_TRIGGERS, @@ -89,14 +92,32 @@ async def generate_triggers(): await add_on_boot_triggers(w.config.get(CONF_ON_BOOT, ())) - # Generate align to directives while we're here - if align_to := w.config.get(CONF_ALIGN_TO): + +async def generate_align_tos(config: dict): + """ + Called once, with a full lvgl configuration to emit deferred align_to actions as a component + that executes after the LVGL setup. This is required since align_to actions are not recalculated on layout changes + and so must be applied after the display is properly laid out. + :param config: + :return: + """ + align_tos = tuple( + w for w in widget_map.values() if w.config and CONF_ALIGN_TO in w.config + ) + if align_tos: + async with LambdaContext(where="align_to") as context: + for w in align_tos: + align_to = w.config[CONF_ALIGN_TO] target = widget_map[align_to[CONF_ID]].obj align = literal(align_to[CONF_ALIGN]) x = align_to[CONF_X] y = align_to[CONF_Y] lv.obj_align_to(w.obj, target, align, x, y) + action_id = config[CONF_ALIGN_TO_LAMBDA_ID] + var = new_Pvariable(action_id, await context.get_lambda()) + await register_component(var, {}) + async def add_trigger(conf, w, *events, is_selected=None): is_selected = is_selected or w.is_selected() diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 8343a542a9..686e429267 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -1,7 +1,7 @@ from esphome import automation, codegen as cg from esphome.const import CONF_TEXT, CONF_VALUE from esphome.cpp_generator import MockObj -from esphome.cpp_types import esphome_ns +from esphome.cpp_types import Component, esphome_ns from .defines import lvgl_ns @@ -51,7 +51,7 @@ IdleTrigger = lvgl_ns.class_("IdleTrigger", automation.Trigger.template()) ObjUpdateAction = lvgl_ns.class_("ObjUpdateAction", automation.Action) LvglCondition = lvgl_ns.class_("LvglCondition", automation.Condition) LvglAction = lvgl_ns.class_("LvglAction", automation.Action) -lv_lambda_t = lvgl_ns.class_("LvLambdaType") +lv_lambda_t = lvgl_ns.class_("LvLambdaComponent", Component) LvCompound = lvgl_ns.class_("LvCompound") lv_font_t = cg.global_ns.class_("lv_font_t") lv_style_t = cg.global_ns.struct("lv_style_t") diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index b168578a98..821476a72b 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -288,7 +288,9 @@ lvgl: - label: text: "Hello shiny day" text_color: 0xFFFFFF - align: bottom_mid + align_to: + id: hello_label + align: OUT_LEFT_TOP - label: id: setup_lambda_label # Test lambda in widget property during setup (LvContext) From c5eb0eb984deae5f95edeb9b9f873feeb6aec785 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:50:11 +0100 Subject: [PATCH 102/160] [internal_temperature] Add nRF52 Zephyr support (#15297) --- .../internal_temperature.cpp | 46 +++++++++++++++++++ .../components/internal_temperature/sensor.py | 9 +++- .../test.nrf52-adafruit.yaml | 1 + 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/components/internal_temperature/test.nrf52-adafruit.yaml diff --git a/esphome/components/internal_temperature/internal_temperature.cpp b/esphome/components/internal_temperature/internal_temperature.cpp index 34d7baf880..567ae6170e 100644 --- a/esphome/components/internal_temperature/internal_temperature.cpp +++ b/esphome/components/internal_temperature/internal_temperature.cpp @@ -22,11 +22,18 @@ extern "C" { uint32_t temp_single_get_current_temperature(uint32_t *temp_value); } #endif // USE_BK72XX +#if defined(USE_ZEPHYR) && defined(USE_NRF52) +#include +#include +#endif // USE_ZEPHYR && USE_NRF52 namespace esphome { namespace internal_temperature { static const char *const TAG = "internal_temperature"; +#if defined(USE_ZEPHYR) && defined(USE_NRF52) +static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); +#endif // USE_ZEPHYR && USE_NRF52 #ifdef USE_ESP32 #if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ @@ -36,6 +43,37 @@ static temperature_sensor_handle_t tsensNew = NULL; #endif // USE_ESP32 void InternalTemperatureSensor::update() { +#if defined(USE_ZEPHYR) && defined(USE_NRF52) + struct sensor_value value; + int result = sensor_sample_fetch(DIE_TEMPERATURE_SENSOR); + if (result != 0) { + ESP_LOGE(TAG, "Failed to fetch nRF52 die temperature sample (%d)", result); + if (!this->has_state()) { + this->publish_state(NAN); + } + return; + } + + result = sensor_channel_get(DIE_TEMPERATURE_SENSOR, SENSOR_CHAN_DIE_TEMP, &value); + if (result != 0) { + ESP_LOGE(TAG, "Failed to get nRF52 die temperature (%d)", result); + if (!this->has_state()) { + this->publish_state(NAN); + } + return; + } + + const float temperature = value.val1 + (value.val2 / 1000000.0f); + if (std::isfinite(temperature)) { + this->publish_state(temperature); + } else { + ESP_LOGD(TAG, "Ignoring invalid nRF52 temperature (value=%.1f)", temperature); + if (!this->has_state()) { + this->publish_state(NAN); + } + } +#else + float temperature = NAN; bool success = false; #ifdef USE_ESP32 @@ -79,9 +117,17 @@ void InternalTemperatureSensor::update() { this->publish_state(NAN); } } +#endif // USE_ZEPHYR && USE_NRF52 } void InternalTemperatureSensor::setup() { +#if defined(USE_ZEPHYR) && defined(USE_NRF52) + if (!device_is_ready(DIE_TEMPERATURE_SENSOR)) { + ESP_LOGE(TAG, "nRF52 die temperature sensor device %s not ready", DIE_TEMPERATURE_SENSOR->name); + this->mark_failed(); + return; + } +#endif // USE_ZEPHYR && USE_NRF52 #ifdef USE_ESP32 #if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index 93b98a30f4..965e7f0520 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -1,15 +1,18 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_TEMPERATURE, ENTITY_CATEGORY_DIAGNOSTIC, PLATFORM_BK72XX, PLATFORM_ESP32, + PLATFORM_NRF52, PLATFORM_RP2040, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.core import CORE internal_temperature_ns = cg.esphome_ns.namespace("internal_temperature") InternalTemperatureSensor = internal_temperature_ns.class_( @@ -25,10 +28,14 @@ CONFIG_SCHEMA = cv.All( state_class=STATE_CLASS_MEASUREMENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ).extend(cv.polling_component_schema("60s")), - cv.only_on([PLATFORM_ESP32, PLATFORM_RP2040, PLATFORM_BK72XX]), + cv.only_on([PLATFORM_ESP32, PLATFORM_RP2040, PLATFORM_BK72XX, PLATFORM_NRF52]), ) async def to_code(config): var = await sensor.new_sensor(config) await cg.register_component(var, config) + + if CORE.using_zephyr and CORE.is_nrf52: + zephyr_add_prj_conf("SENSOR", True) + zephyr_add_prj_conf("TEMP_NRF5", True) diff --git a/tests/components/internal_temperature/test.nrf52-adafruit.yaml b/tests/components/internal_temperature/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/internal_temperature/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 58df755d8bfe43123d59033b82c4dc3f82b0197e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:27:30 -1000 Subject: [PATCH 103/160] Bump requests from 2.33.0 to 2.33.1 (#15324) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0df5caf181..8ad5528c95 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 -requests==2.33.0 +requests==2.33.1 # esp-idf >= 5.0 requires this pyparsing >= 3.0 From 53b2a03c80d22a99fb13aa5f09d665773e414402 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:56:05 -0400 Subject: [PATCH 104/160] [multiple] Fix -Wformat and -Wextra warnings across 33 component files (#15321) --- esphome/components/adc/adc_sensor_esp32.cpp | 10 ++++-- esphome/components/api/api_connection.cpp | 12 +++---- .../media_source/audio_file_media_source.cpp | 3 +- esphome/components/bm8563/bm8563.cpp | 7 ++-- .../components/bme68x_bsec2/bme68x_bsec2.cpp | 4 +-- esphome/components/dlms_meter/dlms_meter.cpp | 4 ++- .../components/esp32_touch/esp32_touch.cpp | 2 +- .../components/espnow/espnow_component.cpp | 4 ++- .../components/http_request/http_request.cpp | 2 +- esphome/components/hub75/hub75.cpp | 4 ++- esphome/components/infrared/infrared.cpp | 11 +++--- esphome/components/inkplate/inkplate.cpp | 34 ++++++++++--------- esphome/components/ld2450/ld2450.cpp | 3 +- .../components/max7219digit/max7219digit.cpp | 5 ++- esphome/components/modbus/modbus.cpp | 2 +- .../components/nextion/nextion_commands.cpp | 4 +-- esphome/components/qmp6988/qmp6988.cpp | 6 ++-- esphome/components/rd03d/rd03d.cpp | 4 ++- .../remote_base/symphony_protocol.cpp | 10 +++--- .../components/runtime_image/bmp_decoder.cpp | 4 ++- .../components/serial_proxy/serial_proxy.cpp | 24 +++++++------ esphome/components/spa06_base/spa06_base.cpp | 4 ++- esphome/components/sps30/sps30.cpp | 8 +++-- .../thermostat/thermostat_climate.cpp | 8 +++-- .../components/tormatic/tormatic_cover.cpp | 11 +++--- .../components/tormatic/tormatic_protocol.h | 4 ++- .../uart/uart_component_esp_idf.cpp | 2 +- .../climate/uponor_smatrix_climate.cpp | 4 ++- .../sensor/uponor_smatrix_sensor.cpp | 4 ++- .../uponor_smatrix/uponor_smatrix.cpp | 14 ++++---- esphome/components/vl53l0x/vl53l0x_sensor.cpp | 6 ++-- .../components/water_heater/water_heater.cpp | 5 ++- .../components/zwave_proxy/zwave_proxy.cpp | 4 ++- 33 files changed, 145 insertions(+), 88 deletions(-) diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index 1d3138623e..fc707013a8 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -2,6 +2,7 @@ #include "adc_sensor.h" #include "esphome/core/log.h" +#include namespace esphome { namespace adc { @@ -346,7 +347,8 @@ float ADCSensor::sample_autorange_() { ESP_LOGVV(TAG, "Autorange summary:"); ESP_LOGVV(TAG, " Raw readings: 12db=%d, 6db=%d, 2.5db=%d, 0db=%d", raw12, raw6, raw2, raw0); ESP_LOGVV(TAG, " Voltages: 12db=%.6f, 6db=%.6f, 2.5db=%.6f, 0db=%.6f", mv12, mv6, mv2, mv0); - ESP_LOGVV(TAG, " Coefficients: c12=%u, c6=%u, c2=%u, c0=%u, sum=%u", c12, c6, c2, c0, csum); + ESP_LOGVV(TAG, " Coefficients: c12=%" PRIu32 ", c6=%" PRIu32 ", c2=%" PRIu32 ", c0=%" PRIu32 ", sum=%" PRIu32, c12, + c6, c2, c0, csum); if (csum == 0) { ESP_LOGE(TAG, "Invalid weight sum in autorange calculation"); @@ -354,8 +356,10 @@ float ADCSensor::sample_autorange_() { } const float final_result = (mv12 * c12 + mv6 * c6 + mv2 * c2 + mv0 * c0) / csum; - ESP_LOGV(TAG, "Autorange final: (%.6f*%u + %.6f*%u + %.6f*%u + %.6f*%u)/%u = %.6fV", mv12, c12, mv6, c6, mv2, c2, mv0, - c0, csum, final_result); + ESP_LOGV(TAG, + "Autorange final: (%.6f*%" PRIu32 " + %.6f*%" PRIu32 " + %.6f*%" PRIu32 " + %.6f*%" PRIu32 ")/%" PRIu32 + " = %.6fV", + mv12, c12, mv6, c6, mv2, c2, mv0, c0, csum, final_result); return final_result; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0a99adcacf..79df85ada3 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1465,7 +1465,7 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { - ESP_LOGW(TAG, "Serial proxy instance %u out of range (max %u)", msg.instance, + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range (max %" PRIu32 ")", msg.instance, static_cast(proxies.size())); return; } @@ -1476,7 +1476,7 @@ void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigure void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { - ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } proxies[msg.instance]->write_from_client(msg.data, msg.data_len); @@ -1485,7 +1485,7 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { - ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } proxies[msg.instance]->set_modem_pins(msg.line_states); @@ -1494,7 +1494,7 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { - ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } SerialProxyGetModemPinsResponse resp{}; @@ -1506,7 +1506,7 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { - ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } switch (msg.type) { @@ -1536,7 +1536,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { break; } default: - ESP_LOGW(TAG, "Unknown serial proxy request type: %u", static_cast(msg.type)); + ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast(msg.type)); break; } } diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.cpp b/esphome/components/audio_file/media_source/audio_file_media_source.cpp index 120f871d2f..fbb5ecd88d 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.cpp +++ b/esphome/components/audio_file/media_source/audio_file_media_source.cpp @@ -4,6 +4,7 @@ #include "esphome/components/audio/audio_decoder.h" +#include #include namespace esphome::audio_file { @@ -249,7 +250,7 @@ void AudioFileMediaSource::decode_task(void *params) { audio::AudioStreamInfo stream_info = decoder->get_audio_stream_info().value(); - ESP_LOGD(TAG, "Bits per sample: %d, Channels: %d, Sample rate: %d", stream_info.get_bits_per_sample(), + ESP_LOGD(TAG, "Bits per sample: %d, Channels: %d, Sample rate: %" PRIu32, stream_info.get_bits_per_sample(), stream_info.get_channels(), stream_info.get_sample_rate()); if (stream_info.get_bits_per_sample() != 16 || stream_info.get_channels() > 2) { diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index 269acfea44..062094c036 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -1,4 +1,7 @@ #include "bm8563.h" + +#include + #include "esphome/core/log.h" namespace esphome::bm8563 { @@ -146,10 +149,10 @@ optional BM8563::read_register_(uint8_t reg) { } void BM8563::set_timer_irq_(uint32_t duration_s) { - ESP_LOGI(TAG, "Timer Duration: %u s", duration_s); + ESP_LOGI(TAG, "Timer Duration: %" PRIu32 " s", duration_s); if (duration_s > MAX_TIMER_DURATION_S) { - ESP_LOGW(TAG, "Timer duration %u s exceeds maximum %u s", duration_s, MAX_TIMER_DURATION_S); + ESP_LOGW(TAG, "Timer duration %" PRIu32 " s exceeds maximum %" PRIu32 " s", duration_s, MAX_TIMER_DURATION_S); return; } diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index d9e00e65b2..d4ac57d750 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -89,7 +89,7 @@ void BME68xBSEC2Component::dump_config() { " Operating age: %s\n" " Sample rate: %s\n" " Voltage: %s\n" - " State save interval: %ims\n" + " State save interval: %" PRIu32 "ms\n" " Temperature offset: %.2f", BME68X_BSEC2_OPERATING_AGE_LOG(this->operating_age_), BME68X_BSEC2_SAMPLE_RATE_LOG(this->sample_rate_), BME68X_BSEC2_VOLTAGE_LOG(this->voltage_), this->state_save_interval_ms_, this->temperature_offset_); @@ -283,7 +283,7 @@ void BME68xBSEC2Component::run_() { if (this->bsec_settings_.trigger_measurement && this->bsec_settings_.op_mode != BME68X_SLEEP_MODE) { bme68x_get_conf(&bme68x_conf, &this->bme68x_); uint32_t meas_dur = bme68x_get_meas_dur(this->op_mode_, &bme68x_conf, &this->bme68x_); - ESP_LOGV(TAG, "Queueing read in %uus", meas_dur); + ESP_LOGV(TAG, "Queueing read in %" PRIu32 "us", meas_dur); this->trigger_time_ns_ = curr_time_ns; this->set_timeout("read", meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); } else { diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index 052a0f4d01..b732e71d24 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -1,5 +1,7 @@ #include "dlms_meter.h" +#include + #if defined(USE_ESP8266_FRAMEWORK_ARDUINO) #include #elif defined(USE_ESP32) @@ -21,7 +23,7 @@ void DlmsMeterComponent::dump_config() { ESP_LOGCONFIG(TAG, "DLMS Meter:\n" " Provider: %s\n" - " Read Timeout: %u ms", + " Read Timeout: %" PRIu32 " ms", provider_name, this->read_timeout_); #define DLMS_METER_LOG_SENSOR(s) LOG_SENSOR(" ", #s, this->s##_sensor_); DLMS_METER_SENSOR_LIST(DLMS_METER_LOG_SENSOR, ) diff --git a/esphome/components/esp32_touch/esp32_touch.cpp b/esphome/components/esp32_touch/esp32_touch.cpp index 0d331b29d6..e44bc807e9 100644 --- a/esphome/components/esp32_touch/esp32_touch.cpp +++ b/esphome/components/esp32_touch/esp32_touch.cpp @@ -217,7 +217,7 @@ void ESP32TouchComponent::setup() { for (uint32_t i = 0; i < ONESHOT_SCAN_COUNT; i++) { err = touch_sensor_trigger_oneshot_scanning(this->sens_handle_, ONESHOT_SCAN_TIMEOUT_MS); if (err != ESP_OK) { - ESP_LOGW(TAG, "Oneshot scan %d failed: %s", i, esp_err_to_name(err)); + ESP_LOGW(TAG, "Oneshot scan %" PRIu32 " failed: %s", i, esp_err_to_name(err)); } } diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 78916891f4..0dc0f12e7e 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -4,6 +4,8 @@ #include "espnow_err.h" +#include + #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -266,7 +268,7 @@ void ESPNowComponent::loop() { if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) { int32_t new_channel = wifi::global_wifi_component->get_wifi_channel(); if (new_channel != this->wifi_channel_) { - ESP_LOGI(TAG, "Wifi Channel is changed from %d to %d.", this->wifi_channel_, new_channel); + ESP_LOGI(TAG, "Wifi Channel is changed from %d to %" PRId32 ".", this->wifi_channel_, new_channel); this->wifi_channel_ = new_channel; } } diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp index 6590d2018e..2c74638f12 100644 --- a/esphome/components/http_request/http_request.cpp +++ b/esphome/components/http_request/http_request.cpp @@ -11,7 +11,7 @@ static const char *const TAG = "http_request"; void HttpRequestComponent::dump_config() { ESP_LOGCONFIG(TAG, "HTTP Request:\n" - " Timeout: %ums\n" + " Timeout: %" PRIu32 "ms\n" " User-Agent: %s\n" " Follow redirects: %s\n" " Redirect limit: %d", diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index cf8661b2b3..ba652d427d 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -1,6 +1,8 @@ #include "hub75_component.h" #include "esphome/core/application.h" +#include + #ifdef USE_ESP32 namespace esphome::hub75 { @@ -58,7 +60,7 @@ void HUB75Display::dump_config() { config_.pins.oe, config_.pins.clk); ESP_LOGCONFIG(TAG, - " Clock Speed: %u MHz\n" + " Clock Speed: %" PRIu32 " MHz\n" " Latch Blanking: %i\n" " Clock Phase: %s\n" " Min Refresh Rate: %i Hz\n" diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 658c9fd0df..9b97995a96 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -1,4 +1,7 @@ #include "infrared.h" + +#include + #include "esphome/core/log.h" #ifdef USE_API @@ -100,7 +103,7 @@ void Infrared::control(const InfraredCall &call) { // Zero-copy from packed protobuf data transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), call.get_packed_count()); - ESP_LOGD(TAG, "Transmitting packed raw timings: count=%u, repeat=%u", call.get_packed_count(), + ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), call.get_repeat_count()); } else if (call.is_base64url()) { // Decode base64url (URL-safe) into transmit buffer @@ -113,16 +116,16 @@ void Infrared::control(const InfraredCall &call) { for (int32_t timing : transmit_data->get_data()) { int32_t abs_timing = timing < 0 ? -timing : timing; if (abs_timing > max_timing_us) { - ESP_LOGE(TAG, "Invalid timing value: %d µs (max %d)", timing, max_timing_us); + ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); return; } } - ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%u", transmit_data->get_data().size(), + ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), call.get_repeat_count()); } else { // From vector (lambdas/automations) transmit_data->set_data(call.get_raw_timings()); - ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%u", call.get_raw_timings().size(), + ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), call.get_repeat_count()); } diff --git a/esphome/components/inkplate/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp index 3b4b1a63d5..0511b451a8 100644 --- a/esphome/components/inkplate/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -3,6 +3,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + #include namespace esphome { @@ -193,7 +195,7 @@ void Inkplate::dump_config() { ESP_LOGCONFIG(TAG, " Greyscale: %s\n" " Partial Updating: %s\n" - " Full Update Every: %d", + " Full Update Every: %" PRIu32, YESNO(this->greyscale_), YESNO(this->partial_updating_), this->full_update_every_); // Log pins LOG_PIN(" CKV Pin: ", this->ckv_pin_); @@ -306,7 +308,7 @@ void Inkplate::fill(Color color) { // If clipping is active, fall back to base implementation if (this->get_clipping().is_set()) { Display::fill(color); - ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Fill finished (%" PRIu32 "ms)", millis() - start_time); return; } @@ -329,12 +331,12 @@ void Inkplate::display() { this->display3b_(); } else { if (this->partial_updating_ && this->partial_update_()) { - ESP_LOGV(TAG, "Display finished (partial) (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display finished (partial) (%" PRIu32 "ms)", millis() - start_time); return; } this->display1b_(); } - ESP_LOGV(TAG, "Display finished (full) (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display finished (full) (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::display1b_() { @@ -409,7 +411,7 @@ void Inkplate::display1b_() { uint32_t clock = (1UL << this->cl_pin_->get_pin()); uint32_t data_mask = this->get_data_pin_mask_(); - ESP_LOGV(TAG, "Display1b start loops (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display1b start loops (%" PRIu32 "ms)", millis() - start_time); for (uint8_t k = 0; k < rep; k++) { buffer_ptr = &this->buffer_[this->get_buffer_length_() - 1]; @@ -440,7 +442,7 @@ void Inkplate::display1b_() { } delayMicroseconds(230); } - ESP_LOGV(TAG, "Display1b first loop x %d (%ums)", 4, millis() - start_time); + ESP_LOGV(TAG, "Display1b first loop x %d (%" PRIu32 "ms)", 4, millis() - start_time); buffer_ptr = &this->buffer_[this->get_buffer_length_() - 1]; vscan_start_(); @@ -469,7 +471,7 @@ void Inkplate::display1b_() { vscan_end_(); } delayMicroseconds(230); - ESP_LOGV(TAG, "Display1b second loop (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display1b second loop (%" PRIu32 "ms)", millis() - start_time); if (this->model_ == INKPLATE_6_PLUS) { clean_fast_(2, 2); @@ -495,13 +497,13 @@ void Inkplate::display1b_() { vscan_end_(); } delayMicroseconds(230); - ESP_LOGV(TAG, "Display1b third loop (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display1b third loop (%" PRIu32 "ms)", millis() - start_time); } vscan_start_(); eink_off_(); this->block_partial_ = false; this->partial_updates_ = 0; - ESP_LOGV(TAG, "Display1b finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display1b finished (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::display3b_() { @@ -614,7 +616,7 @@ void Inkplate::display3b_() { clean_fast_(3, 1); vscan_start_(); eink_off_(); - ESP_LOGV(TAG, "Display3b finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display3b finished (%" PRIu32 "ms)", millis() - start_time); } bool Inkplate::partial_update_() { @@ -641,7 +643,7 @@ bool Inkplate::partial_update_() { this->partial_buffer_2_[n--] = LUTW[diffw & 0x0F] & LUTB[diffb & 0x0F]; } } - ESP_LOGV(TAG, "Partial update buffer built after (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Partial update buffer built after (%" PRIu32 "ms)", millis() - start_time); int rep = (this->model_ == INKPLATE_6_V2) ? 6 : 5; @@ -667,7 +669,7 @@ bool Inkplate::partial_update_() { vscan_end_(); } delayMicroseconds(230); - ESP_LOGV(TAG, "Partial update loop k=%d (%ums)", k, millis() - start_time); + ESP_LOGV(TAG, "Partial update loop k=%d (%" PRIu32 "ms)", k, millis() - start_time); } clean_fast_(2, 2); clean_fast_(3, 1); @@ -675,7 +677,7 @@ bool Inkplate::partial_update_() { eink_off_(); memcpy(this->buffer_, this->partial_buffer_, this->get_buffer_length_()); - ESP_LOGV(TAG, "Partial update finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Partial update finished (%" PRIu32 "ms)", millis() - start_time); return true; } @@ -730,7 +732,7 @@ void Inkplate::clean() { clean_fast_(0, 8); // Black to Black clean_fast_(2, 1); // Black to White clean_fast_(1, 10); // White to White - ESP_LOGV(TAG, "Clean finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Clean finished (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::clean_fast_(uint8_t c, uint8_t rep) { @@ -773,9 +775,9 @@ void Inkplate::clean_fast_(uint8_t c, uint8_t rep) { vscan_end_(); } delayMicroseconds(230); - ESP_LOGV(TAG, "Clean fast rep loop %d finished (%ums)", k, millis() - start_time); + ESP_LOGV(TAG, "Clean fast rep loop %d finished (%" PRIu32 "ms)", k, millis() - start_time); } - ESP_LOGV(TAG, "Clean fast finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Clean fast finished (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::pins_z_state_() { diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 6230a8c30b..58c3cac42d 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -10,6 +10,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include #include #include @@ -575,7 +576,7 @@ void LD2450Component::handle_periodic_data_() { if (this->get_timeout_status_(this->presence_millis_)) { this->target_binary_sensor_->publish_state(false); } else { - ESP_LOGV(TAG, "Clear presence waiting timeout: %d", this->timeout_); + ESP_LOGV(TAG, "Clear presence waiting timeout: %" PRIu32, this->timeout_); } } } diff --git a/esphome/components/max7219digit/max7219digit.cpp b/esphome/components/max7219digit/max7219digit.cpp index cdceafad50..f9b46cf797 100644 --- a/esphome/components/max7219digit/max7219digit.cpp +++ b/esphome/components/max7219digit/max7219digit.cpp @@ -6,6 +6,7 @@ #include "max7219font.h" #include +#include namespace esphome { namespace max7219digit { @@ -92,7 +93,9 @@ void MAX7219Component::loop() { if (this->scroll_mode_ == ScrollMode::STOP) { if (static_cast(this->stepsleft_ + get_width_internal()) == first_line_size + 1) { if (millis_since_last_scroll < this->scroll_dwell_) { - ESP_LOGVV(TAG, "Dwell time at end of string in case of stop at end. Step %d, since last scroll %d, dwell %d.", + ESP_LOGVV(TAG, + "Dwell time at end of string in case of stop at end. Step %d, since last scroll %" PRIu32 + ", dwell %d.", this->stepsleft_, millis_since_last_scroll, this->scroll_dwell_); return; } diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 4146a54c87..3b1a038be3 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -313,7 +313,7 @@ void Modbus::send_next_frame_() { this->last_send_ = millis(); this->tx_buffer_.pop_front(); if (!this->tx_buffer_.empty()) { - ESP_LOGV(TAG, "Write queue contains %" PRIu32 " items.", this->tx_buffer_.size()); + ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size()); } } diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index 6718646efa..6c8e0f18bc 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -319,14 +319,14 @@ void Nextion::filled_circle(uint16_t center_x, uint16_t center_y, uint16_t radiu void Nextion::qrcode(uint16_t x1, uint16_t y1, const char *content, uint16_t size, uint16_t background_color, uint16_t foreground_color, int32_t logo_pic, uint8_t border_width) { this->add_no_result_to_queue_with_printf_( - "qrcode", "qrcode %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu8 ",%" PRIu8 ",\"%s\"", x1, + "qrcode", "qrcode %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRId32 ",%" PRIu8 ",\"%s\"", x1, y1, size, background_color, foreground_color, logo_pic, border_width, content); } void Nextion::qrcode(uint16_t x1, uint16_t y1, const char *content, uint16_t size, Color background_color, Color foreground_color, int32_t logo_pic, uint8_t border_width) { this->add_no_result_to_queue_with_printf_( - "qrcode", "qrcode %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu8 ",%" PRIu8 ",\"%s\"", x1, + "qrcode", "qrcode %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRId32 ",%" PRIu8 ",\"%s\"", x1, y1, size, display::ColorUtil::color_to_565(background_color), display::ColorUtil::color_to_565(foreground_color), logo_pic, border_width, content); } diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 17d91c3633..976efe7910 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -1,4 +1,6 @@ #include "qmp6988.h" + +#include #include namespace esphome { @@ -129,7 +131,7 @@ bool QMP6988Component::get_calibration_data_() { ESP_LOGV(TAG, "Calibration data:\n" - " COE_a0[%d] COE_a1[%d] COE_a2[%d] COE_b00[%d]\n" + " COE_a0[%" PRId32 "] COE_a1[%d] COE_a2[%d] COE_b00[%" PRId32 "]\n" " COE_bt1[%d] COE_bt2[%d] COE_bp1[%d] COE_b11[%d]\n" " COE_bp2[%d] COE_b12[%d] COE_b21[%d] COE_bp3[%d]", qmp6988_data_.qmp6988_cali.COE_a0, qmp6988_data_.qmp6988_cali.COE_a1, qmp6988_data_.qmp6988_cali.COE_a2, @@ -153,7 +155,7 @@ bool QMP6988Component::get_calibration_data_() { qmp6988_data_.ik.bp3 = 2915L * (int64_t) qmp6988_data_.qmp6988_cali.COE_bp3 + 157155561L; // 28Q65 ESP_LOGV(TAG, "Int calibration data:\n" - " a0[%d] a1[%d] a2[%d] b00[%d]\n" + " a0[%" PRId32 "] a1[%" PRId32 "] a2[%" PRId32 "] b00[%" PRId32 "]\n" " bt1[%lld] bt2[%lld] bp1[%lld] b11[%lld]\n" " bp2[%lld] b12[%lld] b21[%lld] bp3[%lld]", qmp6988_data_.ik.a0, qmp6988_data_.ik.a1, qmp6988_data_.ik.a2, qmp6988_data_.ik.b00, qmp6988_data_.ik.bt1, diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index d47347fcfa..c9c6a546ab 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -1,6 +1,8 @@ #include "rd03d.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" + +#include #include namespace esphome::rd03d { @@ -56,7 +58,7 @@ void RD03DComponent::dump_config() { *this->tracking_mode_ == TrackingMode::SINGLE_TARGET ? "single" : "multi"); } if (this->throttle_ > 0) { - ESP_LOGCONFIG(TAG, " Throttle: %ums", this->throttle_); + ESP_LOGCONFIG(TAG, " Throttle: %" PRIu32 "ms", this->throttle_); } #ifdef USE_SENSOR LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); diff --git a/esphome/components/remote_base/symphony_protocol.cpp b/esphome/components/remote_base/symphony_protocol.cpp index f30a980d91..6844e449ed 100644 --- a/esphome/components/remote_base/symphony_protocol.cpp +++ b/esphome/components/remote_base/symphony_protocol.cpp @@ -1,6 +1,8 @@ #include "symphony_protocol.h" #include "esphome/core/log.h" +#include + namespace esphome { namespace remote_base { @@ -26,8 +28,8 @@ static constexpr uint32_t INTER_FRAME_GAP_US = 34760; void SymphonyProtocol::encode(RemoteTransmitData *dst, const SymphonyData &data) { dst->set_carrier_frequency(CARRIER_FREQUENCY); - ESP_LOGD(TAG, "Sending Symphony: data=0x%0*X nbits=%u repeats=%u", (data.nbits + 3) / 4, (uint32_t) data.data, - data.nbits, data.repeats); + ESP_LOGD(TAG, "Sending Symphony: data=0x%0*" PRIX32 " nbits=%" PRIu8 " repeats=%" PRIu8, (data.nbits + 3) / 4, + (uint32_t) data.data, data.nbits, data.repeats); // Each bit produces a mark+space (2 entries). We fold the inter-frame/footer gap // into the last bit's space of each frame to avoid over-length gaps. dst->reserve(data.nbits * 2u * data.repeats); @@ -112,8 +114,8 @@ optional SymphonyProtocol::decode(RemoteReceiveData src) { } void SymphonyProtocol::dump(const SymphonyData &data) { - const int32_t hex_width = (data.nbits + 3) / 4; // pad to nibble width - ESP_LOGI(TAG, "Received Symphony: data=0x%0*X, nbits=%d", hex_width, (uint32_t) data.data, data.nbits); + const int hex_width = (data.nbits + 3) / 4; // pad to nibble width + ESP_LOGI(TAG, "Received Symphony: data=0x%0*" PRIX32 ", nbits=%" PRIu8, hex_width, (uint32_t) data.data, data.nbits); } } // namespace remote_base diff --git a/esphome/components/runtime_image/bmp_decoder.cpp b/esphome/components/runtime_image/bmp_decoder.cpp index 174f924b28..6a1bd61d86 100644 --- a/esphome/components/runtime_image/bmp_decoder.cpp +++ b/esphome/components/runtime_image/bmp_decoder.cpp @@ -6,6 +6,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::runtime_image { static const char *const TAG = "image_decoder.bmp"; @@ -107,7 +109,7 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { } if (this->compression_method_ != 0) { - ESP_LOGE(TAG, "Unsupported compression method: %d", this->compression_method_); + ESP_LOGE(TAG, "Unsupported compression method: %" PRIu32, this->compression_method_); return DECODE_ERROR_UNSUPPORTED_FORMAT; } diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index f3c256c62a..04c94e9292 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -3,6 +3,8 @@ #ifdef USE_SERIAL_PROXY #include "esphome/core/log.h" + +#include #include "esphome/core/util.h" #ifdef USE_API @@ -74,7 +76,7 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) { void SerialProxy::dump_config() { ESP_LOGCONFIG(TAG, - "Serial Proxy [%u]:\n" + "Serial Proxy [%" PRIu32 "]:\n" " Name: %s\n" " Port Type: %s\n" " RTS Pin: %s\n" @@ -89,7 +91,9 @@ void SerialProxy::dump_config() { void SerialProxy::configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, uint8_t data_size) { - ESP_LOGD(TAG, "Configuring serial proxy [%u]: baud=%u, flow_ctrl=%s, parity=%u, stop=%u, data=%u", + ESP_LOGD(TAG, + "Configuring serial proxy [%" PRIu32 "]: baud=%" PRIu32 ", flow_ctrl=%s, parity=%" PRIu8 ", stop=%" PRIu8 + ", data=%" PRIu8, this->instance_index_, baudrate, YESNO(flow_control), parity, stop_bits, data_size); auto *uart_comp = this->parent_; @@ -148,7 +152,7 @@ void SerialProxy::write_from_client(const uint8_t *data, size_t len) { void SerialProxy::set_modem_pins(uint32_t line_states) { const bool rts = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_RTS) != 0; const bool dtr = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_DTR) != 0; - ESP_LOGV(TAG, "Setting modem pins [%u]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr)); + ESP_LOGV(TAG, "Setting modem pins [%" PRIu32 "]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr)); if (this->rts_pin_ != nullptr) { this->rts_state_ = rts; @@ -161,12 +165,12 @@ void SerialProxy::set_modem_pins(uint32_t line_states) { } uint32_t SerialProxy::get_modem_pins() const { - return (this->rts_state_ ? SERIAL_PROXY_LINE_STATE_FLAG_RTS : 0u) | - (this->dtr_state_ ? SERIAL_PROXY_LINE_STATE_FLAG_DTR : 0u); + return (this->rts_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | + (this->dtr_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); } uart::UARTFlushResult SerialProxy::flush_port() { - ESP_LOGV(TAG, "Flushing serial proxy [%u]", this->instance_index_); + ESP_LOGV(TAG, "Flushing serial proxy [%" PRIu32 "]", this->instance_index_); return this->flush(); } @@ -180,19 +184,19 @@ void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api:: } this->api_connection_ = api_connection; this->enable_loop(); - ESP_LOGV(TAG, "API connection subscribed to serial proxy [%u]", this->instance_index_); + ESP_LOGV(TAG, "API connection subscribed to serial proxy [%" PRIu32 "]", this->instance_index_); break; case api::enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE: if (this->api_connection_ != api_connection) { - ESP_LOGV(TAG, "API connection is not subscribed to serial proxy [%u]", this->instance_index_); + ESP_LOGV(TAG, "API connection is not subscribed to serial proxy [%" PRIu32 "]", this->instance_index_); return; } this->api_connection_ = nullptr; this->disable_loop(); - ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%u]", this->instance_index_); + ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_); break; default: - ESP_LOGW(TAG, "Unknown serial proxy request type: %u", static_cast(type)); + ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast(type)); break; } } diff --git a/esphome/components/spa06_base/spa06_base.cpp b/esphome/components/spa06_base/spa06_base.cpp index 36268aa9a2..b0490628cb 100644 --- a/esphome/components/spa06_base/spa06_base.cpp +++ b/esphome/components/spa06_base/spa06_base.cpp @@ -1,5 +1,7 @@ #include "spa06_base.h" +#include + #include "esphome/core/helpers.h" namespace esphome::spa06_base { @@ -195,7 +197,7 @@ bool SPA06Component::read_coefficients_() { ESP_LOGV(TAG, "Coefficients:\n" " c0: %i, c1: %i,\n" - " c00: %i, c10: %i, c20: %i, c30: %i, c40: %i,\n" + " c00: %" PRIi32 ", c10: %" PRIi32 ", c20: %i, c30: %i, c40: %i,\n" " c01: %i, c11: %i, c21: %i, c31: %i", this->c0_, this->c1_, this->c00_, this->c10_, this->c20_, this->c30_, this->c40_, this->c01_, this->c11_, this->c21_, this->c31_); diff --git a/esphome/components/sps30/sps30.cpp b/esphome/components/sps30/sps30.cpp index dbb44743d2..e4fc4ffd31 100644 --- a/esphome/components/sps30/sps30.cpp +++ b/esphome/components/sps30/sps30.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "sps30.h" +#include + namespace esphome { namespace sps30 { @@ -105,7 +107,7 @@ void SPS30Component::dump_config() { " Firmware version v%0d.%0d", this->serial_number_, this->raw_firmware_version_ >> 8, this->raw_firmware_version_ & 0xFF); if (this->idle_interval_.has_value()) { - ESP_LOGCONFIG(TAG, " Idle interval: %us", this->idle_interval_.value() / 1000); + ESP_LOGCONFIG(TAG, " Idle interval: %" PRIu32 "s", this->idle_interval_.value() / 1000); } LOG_SENSOR(" ", "PM1.0 Weight Concentration", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM2.5 Weight Concentration", this->pm_2_5_sensor_); @@ -142,8 +144,8 @@ void SPS30Component::update() { // If its not time to take an action, do nothing. const uint32_t update_start_ms = millis(); if (this->next_state_ != NONE && (int32_t) (this->next_state_ms_ - update_start_ms) > 0) { - ESP_LOGD(TAG, "Sensor waiting for %ums before transitioning to state %d.", (this->next_state_ms_ - update_start_ms), - this->next_state_); + ESP_LOGD(TAG, "Sensor waiting for %" PRIu32 "ms before transitioning to state %d.", + (this->next_state_ms_ - update_start_ms), this->next_state_); return; } diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index eb3e756bc2..d979359c1f 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -2,6 +2,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include namespace esphome::thermostat { @@ -1346,15 +1347,16 @@ void ThermostatClimate::set_timer_duration_in_sec_(ThermostatClimateTimerIndex t if (elapsed >= new_duration_ms) { // Timer should complete immediately (including when new_duration_ms is 0) - ESP_LOGVV(TAG, "timer %d completing immediately (elapsed %d >= new %d)", timer_index, elapsed, new_duration_ms); + ESP_LOGVV(TAG, "timer %d completing immediately (elapsed %" PRIu32 " >= new %" PRIu32 ")", timer_index, elapsed, + new_duration_ms); this->timer_[timer_index].active = false; // Trigger the timer callback immediately this->call_timer_callback_(timer_index); return; } else { // Adjust timer to run for remaining time - keep original start time - ESP_LOGVV(TAG, "timer %d adjusted: elapsed %d, new total %d, remaining %d", timer_index, elapsed, new_duration_ms, - new_duration_ms - elapsed); + ESP_LOGVV(TAG, "timer %d adjusted: elapsed %" PRIu32 ", new total %" PRIu32 ", remaining %" PRIu32, timer_index, + elapsed, new_duration_ms, new_duration_ms - elapsed); this->timer_[timer_index].time = new_duration_ms; return; } diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp index 37a269088e..77c2e87717 100644 --- a/esphome/components/tormatic/tormatic_cover.cpp +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -1,3 +1,4 @@ +#include #include #include "tormatic_cover.h" @@ -120,11 +121,11 @@ void Tormatic::recalibrate_duration_(GateStatus s) { if (s == OPENED) { this->open_duration_ = now - this->direction_start_time_; - ESP_LOGI(TAG, "Recalibrated the gate's open duration to %dms", this->open_duration_); + ESP_LOGI(TAG, "Recalibrated the gate's open duration to %" PRIu32 "ms", this->open_duration_); } if (s == CLOSED) { this->close_duration_ = now - this->direction_start_time_; - ESP_LOGI(TAG, "Recalibrated the gate's close duration to %dms", this->close_duration_); + ESP_LOGI(TAG, "Recalibrated the gate's close duration to %" PRIu32 "ms", this->close_duration_); } this->direction_start_time_ = 0; @@ -269,7 +270,7 @@ optional Tormatic::read_gate_status_() { switch (hdr.type) { case STATUS: { if (hdr.payload_size() != sizeof(StatusReply)) { - ESP_LOGE(TAG, "Header specifies payload size %d but size of StatusReply is %d", hdr.payload_size(), + ESP_LOGE(TAG, "Header specifies payload size %" PRIu32 " but size of StatusReply is %zu", hdr.payload_size(), sizeof(StatusReply)); } @@ -294,7 +295,7 @@ optional Tormatic::read_gate_status_() { default: // Unknown message type, drain the remaining amount of bytes specified in // the header. - ESP_LOGE(TAG, "Reading remaining %d payload bytes of unknown type 0x%x", hdr.payload_size(), hdr.type); + ESP_LOGE(TAG, "Reading remaining %" PRIu32 " payload bytes of unknown type 0x%x", hdr.payload_size(), hdr.type); break; } @@ -339,7 +340,7 @@ template optional Tormatic::read_data_() { } obj.byteswap(); - ESP_LOGV(TAG, "Read %s in %d ms", obj.print().c_str(), millis() - start); + ESP_LOGV(TAG, "Read %s in %" PRIu32 " ms", obj.print().c_str(), millis() - start); return obj; } diff --git a/esphome/components/tormatic/tormatic_protocol.h b/esphome/components/tormatic/tormatic_protocol.h index 26a634b630..269b63ff78 100644 --- a/esphome/components/tormatic/tormatic_protocol.h +++ b/esphome/components/tormatic/tormatic_protocol.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/components/cover/cover.h" /** @@ -86,7 +88,7 @@ struct MessageHeader { std::string print() { // 64 bytes: "MessageHeader: seq " + uint16 + ", len " + uint32 + ", type " + type + safety margin char buf[64]; - buf_append_printf(buf, sizeof(buf), 0, "MessageHeader: seq %d, len %d, type %s", this->seq, this->len, + buf_append_printf(buf, sizeof(buf), 0, "MessageHeader: seq %d, len %" PRIu32 ", type %s", this->seq, this->len, message_type_to_str(this->type)); return buf; } diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index cd77cd1189..6d9d44e97f 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -296,7 +296,7 @@ void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) { void IDFUARTComponent::write_array(const uint8_t *data, size_t len) { int32_t write_len = uart_write_bytes(this->uart_num_, data, len); if (write_len != (int32_t) len) { - ESP_LOGW(TAG, "uart_write_bytes failed: %d != %zu", write_len, len); + ESP_LOGW(TAG, "uart_write_bytes failed: %" PRId32 " != %zu", write_len, len); this->mark_failed(); } #ifdef USE_UART_DEBUGGER diff --git a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp index 3eae4d2d96..512a258122 100644 --- a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp +++ b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp @@ -3,6 +3,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome { namespace uponor_smatrix { @@ -10,7 +12,7 @@ static const char *const TAG = "uponor_smatrix.climate"; void UponorSmatrixClimate::dump_config() { LOG_CLIMATE("", "Uponor Smatrix Climate", this); - ESP_LOGCONFIG(TAG, " Device address: 0x%08X", this->address_); + ESP_LOGCONFIG(TAG, " Device address: 0x%08" PRIX32, this->address_); } void UponorSmatrixClimate::loop() { diff --git a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp index 7ee12edcdb..5f690a6879 100644 --- a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp +++ b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp @@ -1,6 +1,8 @@ #include "uponor_smatrix_sensor.h" #include "esphome/core/log.h" +#include + namespace esphome { namespace uponor_smatrix { @@ -9,7 +11,7 @@ static const char *const TAG = "uponor_smatrix.sensor"; void UponorSmatrixSensor::dump_config() { ESP_LOGCONFIG(TAG, "Uponor Smatrix Sensor\n" - " Device address: 0x%08X", + " Device address: 0x%08" PRIX32, this->address_); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "External Temperature", this->external_temperature_sensor_); diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index 4c3a4b05df..1fd53955a0 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -3,6 +3,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome { namespace uponor_smatrix { @@ -24,7 +26,7 @@ void UponorSmatrixComponent::dump_config() { #ifdef USE_TIME if (this->time_id_ != nullptr) { ESP_LOGCONFIG(TAG, " Time synchronization: YES"); - ESP_LOGCONFIG(TAG, " Time master device address: 0x%08X", this->time_device_address_); + ESP_LOGCONFIG(TAG, " Time master device address: 0x%08" PRIX32 "", this->time_device_address_); } #endif @@ -33,7 +35,7 @@ void UponorSmatrixComponent::dump_config() { if (!this->unknown_devices_.empty()) { ESP_LOGCONFIG(TAG, " Detected unknown device addresses:"); for (auto device_address : this->unknown_devices_) { - ESP_LOGCONFIG(TAG, " 0x%08X", device_address); + ESP_LOGCONFIG(TAG, " 0x%08" PRIX32 "", device_address); } } } @@ -103,14 +105,14 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_size(UPONOR_MAX_LOG_BYTES)]; #endif - ESP_LOGV(TAG, "Received packet: addr=%08X, data=%s, crc=%04X", device_address, + ESP_LOGV(TAG, "Received packet: addr=%08" PRIX32 ", data=%s, crc=%04X", device_address, format_hex_to(hex_buf, &packet[4], packet_len - 6), crc); // Handle packet size_t data_len = (packet_len - 6) / 3; if (data_len == 0) { if (packet[4] == UPONOR_ID_REQUEST) - ESP_LOGVV(TAG, "Ignoring request packet for device 0x%08X", device_address); + ESP_LOGVV(TAG, "Ignoring request packet for device 0x%08" PRIX32 "", device_address); return true; } @@ -135,7 +137,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) { if (data[i].id == UPONOR_ID_DATETIME1) found_time = true; if (found_temperature && found_time) { - ESP_LOGI(TAG, "Using detected time device address 0x%08X", device_address); + ESP_LOGI(TAG, "Using detected time device address 0x%08" PRIX32 "", device_address); this->time_device_address_ = device_address; break; } @@ -154,7 +156,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) { // Log unknown device addresses if (!found && !this->unknown_devices_.count(device_address)) { - ESP_LOGI(TAG, "Received packet for unknown device address 0x%08X ", device_address); + ESP_LOGI(TAG, "Received packet for unknown device address 0x%08" PRIX32 " ", device_address); this->unknown_devices_.insert(device_address); } diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.cpp b/esphome/components/vl53l0x/vl53l0x_sensor.cpp index 8a76ed7760..58b5a42675 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.cpp +++ b/esphome/components/vl53l0x/vl53l0x_sensor.cpp @@ -1,6 +1,8 @@ #include "vl53l0x_sensor.h" #include "esphome/core/log.h" +#include + /* * Most of the code in this integration is based on the VL53L0x library * by Pololu (Pololu Corporation), which in turn is based on the VL53L0X @@ -28,8 +30,8 @@ void VL53L0XSensor::dump_config() { LOG_PIN(" Enable Pin: ", this->enable_pin_); } ESP_LOGCONFIG(TAG, - " Timeout: %u%s\n" - " Timing Budget %uus ", + " Timeout: %" PRIu32 "%s\n" + " Timing Budget %" PRIu32 "us ", this->timeout_us_, this->timeout_us_ > 0 ? "us" : " (no timeout)", this->measurement_timing_budget_us_); } diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index 9a74877f0a..9ee8faadee 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -1,5 +1,7 @@ #include "water_heater.h" #include "esphome/core/log.h" + +#include #include "esphome/core/application.h" #include "esphome/core/controller_registry.h" #include "esphome/core/progmem.h" @@ -110,7 +112,8 @@ void WaterHeaterCall::validate_() { auto traits = this->parent_->get_traits(); if (this->mode_.has_value()) { if (!traits.supports_mode(*this->mode_)) { - ESP_LOGW(TAG, "'%s' - Mode %d not supported", this->parent_->get_name().c_str(), *this->mode_); + ESP_LOGW(TAG, "'%s' - Mode %" PRIu32 " not supported", this->parent_->get_name().c_str(), + static_cast(*this->mode_)); this->mode_.reset(); } } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index ad4357663f..7653d2b678 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -3,6 +3,8 @@ #ifdef USE_API #include "esphome/components/api/api_server.h" + +#include #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -160,7 +162,7 @@ void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::en break; default: - ESP_LOGW(TAG, "Unknown request type: %d", type); + ESP_LOGW(TAG, "Unknown request type: %" PRIu32, static_cast(type)); break; } } From ef65e47bc58ef6df76506be39060ce9114d9e3ca Mon Sep 17 00:00:00 2001 From: Guillermo Ruffino Date: Mon, 30 Mar 2026 21:08:50 -0300 Subject: [PATCH 105/160] [schema] generator fixes (#15276) --- esphome/components/sensor/__init__.py | 4 + esphome/config_validation.py | 4 + script/build_language_schema.py | 206 ++++++++++++++++++-------- 3 files changed, 151 insertions(+), 63 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 650f5ed826..626466eefa 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -113,6 +113,7 @@ from esphome.core.entity_helpers import ( setup_unit_of_measurement, ) from esphome.cpp_generator import MockObj, MockObjClass +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -229,7 +230,10 @@ _SENSOR_ENTITY_CATEGORIES = { } +@schema_extractor("enum") def sensor_entity_category(value): + if value == SCHEMA_EXTRACT: + return _SENSOR_ENTITY_CATEGORIES return cv.enum(_SENSOR_ENTITY_CATEGORIES, lower=True)(value) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 56f255a076..45d2cd8117 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -417,10 +417,14 @@ def icon(value): return value +@schema_extractor("use_id") def sub_device_id(value: str | None) -> core.ID | None: # Lazy import to avoid circular imports from esphome.core.config import Device + if value == SCHEMA_EXTRACT: + return Device + if not value: return None diff --git a/script/build_language_schema.py b/script/build_language_schema.py index bea540dc63..09ff999901 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -67,19 +67,16 @@ def get_component_names(): # pylint: disable-next=redefined-outer-name,reimported from esphome.loader import CORE_COMPONENTS_PATH - component_names = ["esphome", "sensor", "esp32", "esp8266"] - skip_components = [] - - for d in CORE_COMPONENTS_PATH.iterdir(): - if ( - not d.name.startswith("__") - and d.is_dir() - and d.name not in component_names - and d.name not in skip_components - ): - component_names.append(d.name) - - return sorted(component_names) + # return sorted( + # ["esphome", "sensor", "esp32", "esp8266", "adc", "touchscreen", "xpt2046"] + # ) + return sorted( + [ + d.name + for d in CORE_COMPONENTS_PATH.iterdir() + if not d.name.startswith("__") and d.is_dir() + ] + ) def load_components(): @@ -120,39 +117,57 @@ from esphome.util import Registry # noqa: E402 # pylint: enable=wrong-import-position +def sort_obj(obj): + if isinstance(obj, dict): + return {k: sort_obj(v) for k, v in sorted(obj.items(), key=lambda x: str(x[0]))} + if isinstance(obj, list): + return [sort_obj(item) for item in obj] + return obj + + def write_file(name, obj): full_path = Path(args.output_path) / f"{name}.json" + sorted_obj = sort_obj(obj) if JSON_DUMP_PRETTY: - json_str = json.dumps(obj, indent=2) + json_str = json.dumps(sorted_obj, indent=2) else: - json_str = json.dumps(obj, separators=(",", ":")) + json_str = json.dumps(sorted_obj, separators=(",", ":")) write_file_if_changed(full_path, json_str) - print(f"Wrote {full_path}") def delete_extra_files(keep_names): output_path = Path(args.output_path) + count = 0 for d in output_path.iterdir(): if d.suffix == ".json" and d.stem not in keep_names: + count += 1 d.unlink() - print(f"Deleted {d}") + return count def register_module_schemas(key, module, manifest=None): + count = 0 for name, schema in module_schemas(module): + count += 1 register_known_schema(key, name, schema) - if manifest and manifest.multi_conf and S_CONFIG_SCHEMA in output[key][S_SCHEMAS]: + if ( + manifest + and manifest.multi_conf + and key in output + and S_CONFIG_SCHEMA in output[key][S_SCHEMAS] + ): # Multi conf should allow list of components # not sure about 2nd part of the if, might be useless config (e.g. as3935) output[key][S_SCHEMAS][S_CONFIG_SCHEMA]["is_list"] = True + return count def register_known_schema(module, name, schema): if module not in output: output[module] = {S_SCHEMAS: {}} config = convert_config(schema, f"{module}/{name}") - if S_TYPE not in config: + if S_TYPE not in config and name != "FINAL_VALIDATE_SCHEMA" and module != "core": print(f"Config var without type: {module}.{name}") output[module][S_SCHEMAS][name] = config @@ -175,14 +190,23 @@ def module_schemas(module): except OSError: # some empty __init__ files module_str = "" - schemas = {} + schemas = [] for m_attr_name in dir(module): m_attr_obj = getattr(module, m_attr_name) if is_convertible_schema(m_attr_obj): - schemas[module_str.find(m_attr_name)] = [m_attr_name, m_attr_obj] + # Find where the name is assigned in the module source to preserve + # definition order. Using ^NAME\s*= (multiline) targets assignments + # at column 0, so "CONFIG_SCHEMA" won't collide with "CONFIG_SCHEMA_BASE". + match = re.search( + r"^" + re.escape(m_attr_name) + r"\s*=", + module_str, + re.MULTILINE, + ) + pos = match.start() if match else -1 + schemas.append((pos, m_attr_name, m_attr_obj)) - for pos in sorted(schemas.keys()): - yield schemas[pos] + for _, name, obj in sorted(schemas, key=lambda x: x[0]): + yield name, obj found_registries = {} @@ -240,9 +264,16 @@ def add_module_registries(domain, module): if len(parts) == 2: reg_domain = parts[0] reg_entry_name = parts[1] - else: - reg_domain = ".".join([parts[1], parts[0]]) - reg_entry_name = parts[2] + elif len(parts) == 3: + # is a platform or a component? + if parts[0] in schema_core[S_PLATFORMS]: + reg_domain = ".".join([parts[1], parts[0]]) + reg_entry_name = parts[2] + elif parts[0] in schema_core[S_COMPONENTS]: + reg_domain = parts[0] + reg_entry_name = ".".join([parts[1], parts[2]]) + else: + print(f"registry {name} is unknown") if reg_domain not in output: output[reg_domain] = {} @@ -252,8 +283,6 @@ def add_module_registries(domain, module): attr_obj[name].schema, f"{reg_domain}/{reg_type}/{reg_entry_name}" ) - # print(f"{domain} - {attr_name} - {name}") - def do_pins(): # do pin registries @@ -330,6 +359,35 @@ def fix_font(): ) +def fix_globals(): + if "globals" not in output: + return + from esphome.components.globals import _NON_RESTORING_SCHEMA + + config = convert_config(_NON_RESTORING_SCHEMA, "globals/CONFIG_SCHEMA") + config["is_list"] = True + output["globals"][S_SCHEMAS][S_CONFIG_SCHEMA] = config + + +def fix_mapping(): + if "mapping" not in output: + return + from esphome.components.mapping import BASE_SCHEMA + + config = convert_config(BASE_SCHEMA, "mapping/CONFIG_SCHEMA") + output["mapping"][S_SCHEMAS][S_CONFIG_SCHEMA] = config + + +def fix_image(): + if "image" not in output: + return + from esphome.components.image import IMAGE_SCHEMA + + config = convert_config(IMAGE_SCHEMA, "image/CONFIG_SCHEMA") + config["is_list"] = True + output["image"][S_SCHEMAS][S_CONFIG_SCHEMA] = config + + def fix_menu(): if "display_menu_base" not in output: return @@ -355,7 +413,7 @@ def fix_menu(): # 4. Configure menu items inside as recursive menu = schemas["MENU_TYPES"][S_SCHEMA][S_CONFIG_VARS]["items"]["types"]["menu"] menu[S_CONFIG_VARS].pop("items") - menu[S_EXTENDS] = ["display_menu_base.MENU_TYPES"] + menu[S_EXTENDS].append("display_menu_base.MENU_TYPES") def get_logger_tags(): @@ -531,7 +589,6 @@ def shrink(): else: arr_s.pop(S_EXTENDS) arr_s |= key_s[S_SCHEMA] - print(x) # simple types should be spread on each component, # for enums so far these are logger.is_log_level, cover.validate_cover_state and pulse_counter.sensor.COUNT_MODE_SCHEMA @@ -580,6 +637,10 @@ def shrink(): domain_schemas[S_SCHEMAS].pop(schema_name) +def is_cv_invalid(schema): + return repr(schema).startswith(".validator") + + def build_schema(): print("Building schema") @@ -610,7 +671,8 @@ def build_schema(): output[domain] = {S_COMPONENTS: {}, S_SCHEMAS: {}} platforms[domain] = {} elif manifest.config_schema is not None: - # e.g. dallas + if is_cv_invalid(manifest.config_schema): + continue output[domain] = {S_SCHEMAS: {S_CONFIG_SCHEMA: {}}} # Generate platforms (e.g. sensor, binary_sensor, climate ) @@ -621,7 +683,9 @@ def build_schema(): # Generate components for domain, manifest in components.items(): if domain not in platforms: - if manifest.config_schema is not None: + if manifest.config_schema is not None and not is_cv_invalid( + manifest.config_schema + ): core_components[domain] = {} if len(manifest.dependencies) > 0: core_components[domain]["dependencies"] = manifest.dependencies @@ -630,14 +694,15 @@ def build_schema(): for platform in platforms: platform_manifest = get_platform(domain=platform, platform=domain) if platform_manifest is not None: - output[platform][S_COMPONENTS][domain] = {} - if len(platform_manifest.dependencies) > 0: - output[platform][S_COMPONENTS][domain]["dependencies"] = ( - platform_manifest.dependencies - ) - register_module_schemas( + count = register_module_schemas( f"{domain}.{platform}", platform_manifest.module, platform_manifest ) + if count > 0: + output[platform][S_COMPONENTS].setdefault(domain, {}) + if len(platform_manifest.dependencies) > 0: + output[platform][S_COMPONENTS][domain]["dependencies"] = ( + platform_manifest.dependencies + ) # Do registries add_module_registries("core", automation) @@ -657,6 +722,9 @@ def build_schema(): fix_remote_receiver() fix_script() fix_font() + fix_globals() + fix_mapping() + fix_image() add_logger_tags() shrink() fix_menu() @@ -677,12 +745,19 @@ def build_schema(): # bundle core inside esphome data["esphome"]["core"] = data.pop("core")["core"] + if GENERATED_ID_TYPES: + print( + "Unconsumed id_type matchers:", + [id_type for _, id_type in GENERATED_ID_TYPES], + ) + if args.check: # do not gen files return for c, s in data.items(): write_file(c, s) - delete_extra_files(data.keys()) + deleted = delete_extra_files(data.keys()) + print(f"Written {len(data.items())} deleted {deleted} files.") def is_convertible_schema(schema): @@ -711,6 +786,30 @@ def convert_config(schema, path): return converted +GENERATED_ID_TYPES = [ + ( + lambda p: p.startswith("i2c/CONFIG_SCHEMA/") and p.endswith("/id"), + {"class": "i2c::I2CBus", "parents": ["Component"]}, + ), + ( + lambda p: p == "uart/CONFIG_SCHEMA/val 1/ext0/all/id", + {"class": "uart::UARTComponent", "parents": ["Component"]}, + ), + ( + lambda p: p == "http_request/CONFIG_SCHEMA/val 1/ext0/all/id", + {"class": "http_request::HttpRequestComponent", "parents": ["Component"]}, + ), + ( + lambda p: ( + p + == "uptime.sensor/CONFIG_SCHEMA/type_timestamp/ext0/ext1/all/time_id/val 1" + ), + {}, + ), + (lambda p: p == "esp_ldo/action/voltage.adjust/all/all/id", {}), +] + + def convert(schema, config_var, path): """config_var can be a config_var or a schema: both are dicts config_var has a S_TYPE property, if this is S_SCHEMA, then it has a S_SCHEMA property @@ -718,9 +817,6 @@ def convert(schema, config_var, path): """ repr_schema = repr(schema) - if path.startswith("ads1115.sensor") and path.endswith("gain"): - print(path) - if repr_schema in known_schemas: schema_info = known_schemas[(repr_schema)] for schema_instance, name in schema_info: @@ -841,8 +937,6 @@ def convert(schema, config_var, path): schema({"delay": "1s"}) except cv.Invalid: config_var["has_required_var"] = True - else: - print("figure out " + path) elif schema_type == "effects": config_var[S_TYPE] = "registry" config_var["registry"] = "light.effects" @@ -879,8 +973,6 @@ def convert(schema, config_var, path): "id" ]["id_type"]["class"] config_var[S_TYPE] = "use_id" - else: - print("TODO deferred?") elif isinstance(data, str): # TODO: Figure out why pipsolar does this config_var["use_id_type"] = data @@ -890,23 +982,11 @@ def convert(schema, config_var, path): else: raise TypeError("Unknown extracted schema type") elif config_var.get("key") == "GeneratedID": - if path.startswith("i2c/CONFIG_SCHEMA/") and path.endswith("/id"): - config_var["id_type"] = { - "class": "i2c::I2CBus", - "parents": ["Component"], - } - elif path == "uart/CONFIG_SCHEMA/val 1/ext0/all/id": - config_var["id_type"] = { - "class": "uart::UARTComponent", - "parents": ["Component"], - } - elif path == "http_request/CONFIG_SCHEMA/val 1/ext0/all/id": - config_var["id_type"] = { - "class": "http_request::HttpRequestComponent", - "parents": ["Component"], - } - elif path == "pins/esp32/val 1/id": - config_var["id_type"] = "pin" + for i, (matcher, id_type) in enumerate(GENERATED_ID_TYPES): + if matcher(path): + config_var["id_type"] = id_type + GENERATED_ID_TYPES.pop(i) + break else: print("Cannot determine id_type for " + path) From a3913b98ba4d41263d9022a09fb91d21b4747384 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 17:05:48 -1000 Subject: [PATCH 106/160] [wifi] Move LibreTiny WiFi STA state to member variable (#15305) --- esphome/components/wifi/wifi_component.h | 8 +++--- .../wifi/wifi_component_libretiny.cpp | 25 +++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 9a08902d47..665dec37d5 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -818,10 +818,10 @@ class WiFiComponent final : public Component { uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ bool error_from_callback_{false}; -#ifdef USE_ESP8266 - // ESP8266WiFiSTAState enum, defined in wifi_component_esp8266.cpp. - // Written from SDK system context (wifi_event_callback) — uint8_t writes - // are atomic on Xtensa LX106 so no synchronization is needed. +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) + // Platform-specific STA state enum, defined in platform cpp file. + // On ESP8266, written from SDK system context (wifi_event_callback) — + // uint8_t writes are atomic on Xtensa LX106 so no synchronization is needed. uint8_t sta_state_{0}; #endif RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY}; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index b049a0413c..9565ffa747 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -97,8 +97,6 @@ enum class LTWiFiSTAState : uint8_t { ERROR_FAILED, // Connection failed (auth, timeout, etc.) }; -static LTWiFiSTAState s_sta_state = LTWiFiSTAState::IDLE; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) - // Count of ignored disconnect events during connection - too many indicates real failure static uint8_t s_ignored_disconnect_count = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) // Threshold for ignored disconnect events before treating as connection failure @@ -223,7 +221,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { this->wifi_apply_hostname_(); // Reset state machine and disconnect counter before connecting - s_sta_state = LTWiFiSTAState::CONNECTING; + this->sta_state_ = static_cast(LTWiFiSTAState::CONNECTING); s_ignored_disconnect_count = 0; WiFiStatus status = WiFi.begin(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), @@ -459,7 +457,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); - s_sta_state = LTWiFiSTAState::IDLE; + this->sta_state_ = static_cast(LTWiFiSTAState::IDLE); break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { @@ -479,7 +477,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { // For static IP configurations, GOT_IP event may not fire, so set connected state here #ifdef USE_WIFI_MANUAL_IP if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { - s_sta_state = LTWiFiSTAState::CONNECTED; + this->sta_state_ = static_cast(LTWiFiSTAState::CONNECTED); #ifdef USE_WIFI_IP_STATE_LISTENERS this->notify_ip_state_listeners_(); #endif @@ -501,12 +499,13 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { // Only ignore benign reasons - real failures like NO_AP_FOUND should still be processed. // However, if we get too many of these events (IGNORED_DISCONNECT_THRESHOLD), treat it // as a real connection failure to avoid waiting the full timeout for a failing connection. - if (it.ssid_len == 0 && s_sta_state == LTWiFiSTAState::CONNECTING && it.reason != WIFI_REASON_NO_AP_FOUND) { + if (it.ssid_len == 0 && this->sta_state_ == static_cast(LTWiFiSTAState::CONNECTING) && + it.reason != WIFI_REASON_NO_AP_FOUND) { s_ignored_disconnect_count++; if (s_ignored_disconnect_count >= IGNORED_DISCONNECT_THRESHOLD) { ESP_LOGW(TAG, "Too many disconnect events (%u) while connecting, treating as failure (reason=%s)", s_ignored_disconnect_count, get_disconnect_reason_str(it.reason)); - s_sta_state = LTWiFiSTAState::ERROR_FAILED; + this->sta_state_ = static_cast(LTWiFiSTAState::ERROR_FAILED); WiFi.disconnect(); this->error_from_callback_ = true; // Don't break - fall through to notify listeners @@ -520,13 +519,13 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { if (it.reason == WIFI_REASON_NO_AP_FOUND) { ESP_LOGW(TAG, "Disconnected ssid='%.*s' reason='Probe Request Unsuccessful'", it.ssid_len, (const char *) it.ssid); - s_sta_state = LTWiFiSTAState::ERROR_NOT_FOUND; + this->sta_state_ = static_cast(LTWiFiSTAState::ERROR_NOT_FOUND); } else { char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, get_disconnect_reason_str(it.reason)); - s_sta_state = LTWiFiSTAState::ERROR_FAILED; + this->sta_state_ = static_cast(LTWiFiSTAState::ERROR_FAILED); } uint8_t reason = it.reason; @@ -551,7 +550,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); WiFi.disconnect(); this->error_from_callback_ = true; - s_sta_state = LTWiFiSTAState::ERROR_FAILED; + this->sta_state_ = static_cast(LTWiFiSTAState::ERROR_FAILED); } break; } @@ -559,7 +558,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { char ip_buf[network::IP_ADDRESS_BUFFER_SIZE], gw_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGV(TAG, "static_ip=%s gateway=%s", network::IPAddress(WiFi.localIP()).str_to(ip_buf), network::IPAddress(WiFi.gatewayIP()).str_to(gw_buf)); - s_sta_state = LTWiFiSTAState::CONNECTED; + this->sta_state_ = static_cast(LTWiFiSTAState::CONNECTED); #ifdef USE_WIFI_IP_STATE_LISTENERS this->notify_ip_state_listeners_(); #endif @@ -637,7 +636,7 @@ void WiFiComponent::wifi_pre_setup_() { WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { // Use state machine instead of querying WiFi.status() directly // State is updated in main loop from queued events, ensuring thread safety - switch (s_sta_state) { + switch (static_cast(this->sta_state_)) { case LTWiFiSTAState::CONNECTED: return WiFiSTAConnectStatus::CONNECTED; case LTWiFiSTAState::ERROR_NOT_FOUND: @@ -758,7 +757,7 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; bool WiFiComponent::wifi_disconnect_() { // Reset state first so disconnect events aren't ignored // and wifi_sta_connect_status_() returns IDLE instead of CONNECTING - s_sta_state = LTWiFiSTAState::IDLE; + this->sta_state_ = static_cast(LTWiFiSTAState::IDLE); return WiFi.disconnect(); } From ceb3cb2ae797611e73f14f3887df812778600ed6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:22:29 -0400 Subject: [PATCH 107/160] [haier] Fix hOn half-degree temperature setting (#15312) --- esphome/components/haier/hon_climate.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 1cee95bf16..1e9cb42f38 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -675,7 +675,6 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { this->quiet_mode_state_ = (SwitchState) ((uint8_t) this->quiet_mode_state_ & 0b01); } out_data->beeper_status = ((!this->get_beeper_state()) || (!has_hvac_settings)) ? 1 : 0; - control_out_buffer[4] = 0; // This byte should be cleared before setting values out_data->display_status = this->get_display_state() ? 1 : 0; this->display_status_ = (SwitchState) ((uint8_t) this->display_status_ & 0b01); out_data->health_mode = this->get_health_mode() ? 1 : 0; From c64bc2496093dfd0f107e15473adff8437574cc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 07:34:54 -1000 Subject: [PATCH 108/160] [preferences] Reduce log verbosity for unchanged NVS/FDB writes (#15332) --- esphome/components/esp32/preferences.cpp | 12 ++++++++---- esphome/components/libretiny/preferences.cpp | 13 +++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index e88ace3e6b..bc0a34ebe8 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -129,11 +129,15 @@ bool ESP32Preferences::sync() { } s_pending_save.clear(); - ESP_LOGD(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, - failed); if (failed > 0) { - ESP_LOGE(TAG, "Writing %d items failed. Last error=%s for key=%" PRIu32, failed, esp_err_to_name(last_err), - last_key); + ESP_LOGE(TAG, "Writing %d items: %d cached, %d written, %d failed. Last error=%s for key=%" PRIu32, + cached + written + failed, cached, written, failed, esp_err_to_name(last_err), last_key); + } else if (written > 0) { + ESP_LOGD(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, + failed); + } else { + ESP_LOGV(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, + failed); } // note: commit on esp-idf currently is a no-op, nvs_set_blob always writes diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index 344ca4a8b3..fba6717294 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -108,16 +108,21 @@ bool LibreTinyPreferences::sync() { } written++; } else { - ESP_LOGD(TAG, "FDB data not changed; skipping %" PRIu32 " len=%zu", save.key, save.data.size()); + ESP_LOGV(TAG, "FDB data not changed; skipping %" PRIu32 " len=%zu", save.key, save.data.size()); cached++; } } s_pending_save.clear(); - ESP_LOGD(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, - failed); if (failed > 0) { - ESP_LOGE(TAG, "Writing %d items failed. Last error=%d for key=%" PRIu32, failed, last_err, last_key); + ESP_LOGE(TAG, "Writing %d items: %d cached, %d written, %d failed. Last error=%d for key=%" PRIu32, + cached + written + failed, cached, written, failed, last_err, last_key); + } else if (written > 0) { + ESP_LOGD(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, + failed); + } else { + ESP_LOGV(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, + failed); } return failed == 0; From 9b97e95cf3620dc3aad8715e011ec1b89cdbf112 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 07:42:12 -1000 Subject: [PATCH 109/160] [binary_sensor] Add on_multi_click integration test (#15329) --- .../fixtures/multi_click_trigger.yaml | 105 ++++++++++++++++++ tests/integration/test_multi_click_trigger.py | 82 ++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 tests/integration/fixtures/multi_click_trigger.yaml create mode 100644 tests/integration/test_multi_click_trigger.py diff --git a/tests/integration/fixtures/multi_click_trigger.yaml b/tests/integration/fixtures/multi_click_trigger.yaml new file mode 100644 index 0000000000..3bd53d594c --- /dev/null +++ b/tests/integration/fixtures/multi_click_trigger.yaml @@ -0,0 +1,105 @@ +esphome: + name: test-multi-click + +host: +api: + batch_delay: 0ms + services: + - service: run_all_tests + then: + # Prime the binary sensor with an initial OFF state. + # trigger_on_initial_state defaults to false, so the first + # state change from unknown won't fire callbacks. + - binary_sensor.template.publish: + id: test_button + state: false + - delay: 50ms + + # Test 1: Single click (ON < 50ms, OFF >= 30ms) + - binary_sensor.template.publish: + id: test_button + state: true + - delay: 20ms + - binary_sensor.template.publish: + id: test_button + state: false + # Wait for single click trigger (30ms) + cooldown (100ms) + margin + - delay: 200ms + + # Test 2: Double click (ON < 50ms, OFF < 25ms, ON < 50ms, OFF >= 25ms) + - binary_sensor.template.publish: + id: test_button + state: true + - delay: 20ms + - binary_sensor.template.publish: + id: test_button + state: false + - delay: 15ms + - binary_sensor.template.publish: + id: test_button + state: true + - delay: 20ms + - binary_sensor.template.publish: + id: test_button + state: false + # Wait for double click trigger (25ms) + cooldown (100ms) + margin + - delay: 200ms + + # Test 3: Long press (ON >= 80ms) + - binary_sensor.template.publish: + id: test_button + state: true + - delay: 100ms + - binary_sensor.template.publish: + id: test_button + state: false + +logger: + level: VERBOSE + +globals: + - id: single_click_count + type: int + initial_value: "0" + - id: double_click_count + type: int + initial_value: "0" + - id: long_press_count + type: int + initial_value: "0" + +binary_sensor: + - platform: template + name: "Test Button" + id: test_button + on_multi_click: + # Single press + - timing: + - ON for at most 50ms + - OFF for at least 30ms + invalid_cooldown: 100ms + then: + - lambda: |- + id(single_click_count) += 1; + ESP_LOGI("multi_click_test", "SINGLE_CLICK count=%d", id(single_click_count)); + + # Double press + - timing: + - ON for at most 50ms + - OFF for at most 25ms + - ON for at most 50ms + - OFF for at least 25ms + invalid_cooldown: 100ms + then: + - lambda: |- + id(double_click_count) += 1; + ESP_LOGI("multi_click_test", "DOUBLE_CLICK count=%d", id(double_click_count)); + + # Long press + - timing: + - ON for at least 80ms + invalid_cooldown: 100ms + then: + - lambda: |- + id(long_press_count) += 1; + ESP_LOGI("multi_click_test", "LONG_PRESS count=%d", id(long_press_count)); diff --git a/tests/integration/test_multi_click_trigger.py b/tests/integration/test_multi_click_trigger.py new file mode 100644 index 0000000000..8a020dd18b --- /dev/null +++ b/tests/integration/test_multi_click_trigger.py @@ -0,0 +1,82 @@ +"""Integration test for on_multi_click binary sensor automation. + +Tests that on_multi_click correctly triggers for single click, double click, +and long press patterns using a template binary sensor with timing +orchestrated entirely in YAML. + +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_multi_click_trigger( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that on_multi_click triggers for single, double, and long press patterns.""" + loop = asyncio.get_running_loop() + + single_click_pattern = re.compile(r"SINGLE_CLICK count=(\d+)") + double_click_pattern = re.compile(r"DOUBLE_CLICK count=(\d+)") + long_press_pattern = re.compile(r"LONG_PRESS count=(\d+)") + + single_click_future: asyncio.Future[int] = loop.create_future() + double_click_future: asyncio.Future[int] = loop.create_future() + long_press_future: asyncio.Future[int] = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for multi-click trigger messages.""" + if m := single_click_pattern.search(line): + if not single_click_future.done(): + single_click_future.set_result(int(m.group(1))) + elif m := double_click_pattern.search(line): + if not double_click_future.done(): + double_click_future.set_result(int(m.group(1))) + elif (m := long_press_pattern.search(line)) and not long_press_future.done(): + long_press_future.set_result(int(m.group(1))) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _entities, services = await client.list_entities_services() + + test_service = next((s for s in services if s.name == "run_all_tests"), None) + assert test_service is not None, "run_all_tests service not found" + + # Kick off the entire test sequence (runs in YAML with delays) + await client.execute_service(test_service, {}) + + # Wait for all three triggers + try: + count = await asyncio.wait_for(single_click_future, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for SINGLE_CLICK - on_multi_click did not trigger." + ) + assert count == 1, f"Expected single click count=1, got {count}" + + try: + count = await asyncio.wait_for(double_click_future, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for DOUBLE_CLICK - on_multi_click did not trigger." + ) + assert count == 1, f"Expected double click count=1, got {count}" + + try: + count = await asyncio.wait_for(long_press_future, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for LONG_PRESS - on_multi_click did not trigger." + ) + assert count == 1, f"Expected long press count=1, got {count}" From 2c9a3051d6e90e6a89da2a08d52d93160288c300 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 07:43:18 -1000 Subject: [PATCH 110/160] [api] Use memcpy for fixed32 decode on little-endian platforms (#15292) --- esphome/components/api/proto.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index 4f5b3f0918..d9fe0fe461 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -257,7 +257,13 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer)); return; } - uint32_t val = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]); + uint32_t val; +#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + // Protobuf fixed32 is little-endian — direct load on LE platforms + memcpy(&val, ptr, 4); +#else + val = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]); +#endif if (!this->decode_32bit(field_id, Proto32Bit(val))) { ESP_LOGV(TAG, "Cannot decode 32-bit field %" PRIu32 " with value %" PRIu32 "!", field_id, val); } From 2449aa75af91ba01b3b812d5fde43d73eb918d5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 07:45:23 -1000 Subject: [PATCH 111/160] [http_request] Fix crash when esp_http_client_init fails (#15328) --- .../http_request/http_request_idf.cpp | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index dda61e2400..30f53eecdc 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -17,6 +17,7 @@ namespace esphome::http_request { static const char *const TAG = "http_request.idf"; +static constexpr uint32_t ERROR_DURATION_MS = 1000; struct UserData { const std::vector &lower_case_collect_headers; @@ -57,7 +58,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c const std::vector

&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Not connected to network"); return nullptr; } @@ -74,7 +75,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } else if (method == "PATCH") { method_idf = HTTP_METHOD_PATCH; } else { - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Unsupported method"); return nullptr; } @@ -112,6 +113,11 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c config.event_handler = http_event_handler; esp_http_client_handle_t client = esp_http_client_init(&config); + if (client == nullptr) { + this->status_momentary_error("failed", ERROR_DURATION_MS); + ESP_LOGE(TAG, "HTTP Request failed; client could not be initialized"); + return nullptr; + } std::shared_ptr container = std::make_shared(client); container->set_parent(this); @@ -129,7 +135,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c esp_err_t err = esp_http_client_open(client, body_len); if (err != ESP_OK) { - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); return nullptr; @@ -151,7 +157,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } if (err != ESP_OK) { - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); return nullptr; @@ -176,7 +182,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c err = esp_http_client_set_redirection(client); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_http_client_set_redirection failed: %s", esp_err_to_name(err)); - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); esp_http_client_cleanup(client); return nullptr; } @@ -189,7 +195,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c err = esp_http_client_open(client, 0); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_http_client_open failed: %s", esp_err_to_name(err)); - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); esp_http_client_cleanup(client); return nullptr; } @@ -214,7 +220,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), container->status_code); - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); return container; } From 26b426bbffd28611d0ff4880b4196c8a0bde4d13 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 31 Mar 2026 14:34:16 -0500 Subject: [PATCH 112/160] [zwave_proxy] Clear Home ID on USB modem disconnect (#15327) --- esphome/components/uart/uart_component.h | 4 + esphome/components/usb_uart/usb_uart.h | 1 + .../components/zwave_proxy/zwave_proxy.cpp | 76 +++++++++++++++++-- esphome/components/zwave_proxy/zwave_proxy.h | 6 ++ 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index abc77fbae8..afd3ad5777 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -85,6 +85,10 @@ class UARTComponent { // @return UARTFlushResult indicating whether the flush was confirmed, timed out, failed, or assumed successful. virtual UARTFlushResult flush() = 0; + // Returns true if the underlying transport is connected and operational. + // Hardware UARTs always return true. USB-backed UARTs override to reflect actual connection state. + virtual bool is_connected() { return true; } + // Sets the maximum time to wait for TX to drain during flush(). // Only meaningful on ESP32 (IDF). Other platforms ignore this value. // @param flush_timeout_ms Timeout in milliseconds; 0 means wait indefinitely. diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 8a47f0cf4b..8e8e65032d 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -140,6 +140,7 @@ class USBUartChannel : public uart::UARTComponent, public Parentedinput_buffer_.get_available(); } + bool is_connected() override { return this->initialised_.load(); } uart::UARTFlushResult flush() override; void check_logger_conflict() override {} void set_parity(UARTParityOptions parity) { this->parity_ = parity; } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 7653d2b678..ecb38b25e7 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -22,6 +22,8 @@ static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20; static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 9; // TYPE + CMD + HOME_ID(4) + NODE_ID + checksum static constexpr uint32_t HOME_ID_TIMEOUT_MS = 100; // Timeout for waiting for home ID during setup +static constexpr uint32_t RECONNECT_DELAY_MS = 500; // Delay between home ID query attempts after reconnect +static constexpr uint8_t MAX_QUERY_RETRIES = 5; // Max attempts to query home ID after reconnect static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { // Calculate Z-Wave frame checksum @@ -38,7 +40,10 @@ ZWaveProxy::ZWaveProxy() { global_zwave_proxy = this; } void ZWaveProxy::setup() { this->setup_time_ = App.get_loop_component_start_time(); - this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS); + this->was_connected_ = this->parent_->is_connected(); + if (this->was_connected_) { + this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS); + } } float ZWaveProxy::get_setup_priority() const { @@ -84,6 +89,14 @@ void ZWaveProxy::loop() { this->api_connection_ = nullptr; // Unsubscribe if disconnected } + const bool connected = this->parent_->is_connected(); + if (this->was_connected_ != connected) { + this->on_connection_changed_(connected); + } + if (this->reconnect_time_ != 0) { + this->retry_home_id_query_(); + } + this->process_uart_(); this->status_clear_warning(); } @@ -167,6 +180,55 @@ void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::en } } +void ZWaveProxy::on_connection_changed_(bool connected) { + this->was_connected_ = connected; + if (connected) { + ESP_LOGD(TAG, "Modem reconnected"); + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->buffer_index_ = 0; + this->last_response_ = 0; + this->in_bootloader_ = false; + // Defer the query — the modem needs time to initialize after power is applied + this->reconnect_time_ = App.get_loop_component_start_time(); + this->query_retries_ = 0; + } else { + ESP_LOGW(TAG, "Modem disconnected"); + this->clear_home_id_(); + } +} + +void ZWaveProxy::retry_home_id_query_() { + if (this->home_id_ready_) { + // Got the home ID, cancel remaining retries + this->reconnect_time_ = 0; + return; + } + if (App.get_loop_component_start_time() - this->reconnect_time_ <= RECONNECT_DELAY_MS) { + return; // Not yet time for next attempt + } + this->reconnect_time_ = App.get_loop_component_start_time(); // Reset timer for next retry + this->query_retries_++; + if (this->query_retries_ <= MAX_QUERY_RETRIES) { + ESP_LOGD(TAG, "Querying Home ID (attempt %u)", this->query_retries_); + this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS); + } else { + ESP_LOGW(TAG, "Failed to read Home ID after %u attempts", MAX_QUERY_RETRIES); + this->reconnect_time_ = 0; + } +} + +void ZWaveProxy::clear_home_id_() { + static constexpr uint8_t ZERO_HOME_ID[ZWAVE_HOME_ID_SIZE] = {}; + if (this->set_home_id_(ZERO_HOME_ID)) { + this->send_homeid_changed_msg_(); + } + this->home_id_ready_ = false; + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->buffer_index_ = 0; + this->last_response_ = 0; + this->in_bootloader_ = false; +} + bool ZWaveProxy::set_home_id_(const uint8_t *new_home_id) { if (std::memcmp(this->home_id_.data(), new_home_id, this->home_id_.size()) == 0) { ESP_LOGV(TAG, "Home ID unchanged"); @@ -309,7 +371,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; switch (byte) { case ZWAVE_FRAME_TYPE_START: - ESP_LOGVV(TAG, "Received START"); + ESP_LOGV(TAG, "Received START"); if (this->in_bootloader_) { ESP_LOGD(TAG, "Exited bootloader mode"); this->in_bootloader_ = false; @@ -318,7 +380,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH; return; case ZWAVE_FRAME_TYPE_BL_MENU: - ESP_LOGVV(TAG, "Received BL_MENU"); + ESP_LOGV(TAG, "Received BL_MENU"); if (!this->in_bootloader_) { ESP_LOGD(TAG, "Entered bootloader mode"); this->in_bootloader_ = true; @@ -327,16 +389,16 @@ void ZWaveProxy::parse_start_(uint8_t byte) { this->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU; return; case ZWAVE_FRAME_TYPE_BL_BEGIN_UPLOAD: - ESP_LOGVV(TAG, "Received BL_BEGIN_UPLOAD"); + ESP_LOGV(TAG, "Received BL_BEGIN_UPLOAD"); break; case ZWAVE_FRAME_TYPE_ACK: - ESP_LOGVV(TAG, "Received ACK"); + ESP_LOGV(TAG, "Received ACK"); break; case ZWAVE_FRAME_TYPE_NAK: - ESP_LOGW(TAG, "Received NAK"); + ESP_LOGV(TAG, "Received NAK"); break; case ZWAVE_FRAME_TYPE_CAN: - ESP_LOGW(TAG, "Received CAN"); + ESP_LOGV(TAG, "Received CAN"); break; default: ESP_LOGW(TAG, "Unrecognized START: 0x%02X", byte); diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 12cb9a90a1..0b810de29f 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -65,6 +65,9 @@ class ZWaveProxy : public uart::UARTDevice, public Component { protected: bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. + void clear_home_id_(); // Clear home ID and notify API clients + void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions + void retry_home_id_query_(); // Retry home ID query after reconnect void send_homeid_changed_msg_(api::APIConnection *conn = nullptr); void send_simple_command_(uint8_t command_id); bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) @@ -80,14 +83,17 @@ class ZWaveProxy : public uart::UARTDevice, public Component { // Pointers and 32-bit values (aligned together) api::APIConnection *api_connection_{nullptr}; // Current subscribed client uint32_t setup_time_{0}; // Time when setup() was called + uint32_t reconnect_time_{0}; // Timestamp of reconnect detection (0 = no pending query) // Small values (grouped by size to minimize padding) uint16_t buffer_index_{0}; // Index for populating the data buffer uint16_t end_frame_after_{0}; // Payload reception ends after this index uint8_t last_response_{0}; // Last response type sent + uint8_t query_retries_{0}; // Number of home ID query attempts after reconnect ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode bool home_id_ready_{false}; // True when home ID has been received from Z-Wave module + bool was_connected_{false}; // Previous UART connection state for edge detection }; extern ZWaveProxy *global_zwave_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) From da6c4e20fef3f92dfc25ecd5f34df76d2c8baf1a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 1 Apr 2026 06:29:57 +1000 Subject: [PATCH 113/160] [lvgl] Fixes #2 (#15161) --- esphome/components/lvgl/automation.py | 4 +- esphome/components/lvgl/defines.py | 1 + esphome/components/lvgl/lvcode.py | 5 -- esphome/components/lvgl/lvgl_esphome.cpp | 26 ++++---- esphome/components/lvgl/number/__init__.py | 4 +- esphome/components/lvgl/schemas.py | 67 ++++++++++++++++----- esphome/components/lvgl/switch/__init__.py | 4 +- esphome/components/lvgl/text/__init__.py | 4 +- esphome/components/lvgl/trigger.py | 3 + esphome/components/lvgl/widgets/meter.py | 63 ++++++++++--------- esphome/components/lvgl/widgets/tileview.py | 2 +- tests/components/lvgl/lvgl-package.yaml | 34 ++++++++++- 12 files changed, 145 insertions(+), 72 deletions(-) diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index 24579e5be8..50e6db74b8 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -136,7 +136,7 @@ async def update_to_code(config, action_id, template_arg, args): widget.type.w_type.value_property is not None and widget.type.w_type.value_property in config ): - lv.event_send(widget.obj, UPDATE_EVENT, nullptr) + lv_obj.send_event(widget.obj, UPDATE_EVENT, nullptr) widgets = await get_widgets(config[CONF_ID]) return await action_to_code( @@ -455,6 +455,6 @@ async def obj_refresh_to_code(config, action_id, template_arg, args): widget.type.w_type.value_property is not None and widget.type.w_type.value_property in config ): - lv.event_send(widget.obj, UPDATE_EVENT, nullptr) + lv_obj.send_event(widget.obj, UPDATE_EVENT, nullptr) return await action_to_code(widget, do_refresh, action_id, template_arg, args) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 72345ca98e..de5835d7a6 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -541,6 +541,7 @@ CONF_END_ANGLE = "end_angle" CONF_END_VALUE = "end_value" CONF_ENTER_BUTTON = "enter_button" CONF_ENTRIES = "entries" +CONF_EXT_CLICK_AREA = "ext_click_area" CONF_FLAGS = "flags" CONF_FLEX_FLOW = "flex_flow" CONF_FLEX_ALIGN_MAIN = "flex_align_main" diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index 146b261f26..eb8f7d4437 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -253,14 +253,10 @@ class MockLv: A mock object that can be used to generate LVGL calls. """ - # Mapping for LVGL 9 - ATTR_MAP = {"event_send": "obj_send_event", "dither": "bg_dither_mode"} - def __init__(self, base): self.base = base def __getattr__(self, attr: str) -> "MockLv": - attr = MockLv.ATTR_MAP.get(attr, attr) return MockLv(f"{self.base}{attr}") def append(self, expression): @@ -314,7 +310,6 @@ class ReturnStatement(ExpressionStatement): class LvExpr(MockLv): def __getattr__(self, attr: str) -> "MockLv": - attr = MockLv.ATTR_MAP.get(attr, attr) return LvExpr(f"{self.base}{attr}") def append(self, expression): diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index bf86a4e9ee..a5075cb614 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -343,26 +343,26 @@ void IndicatorLine::set_value(int value) { } void IndicatorLine::update_length_() { - uint32_t actual_needle_length; - auto radius = lv_obj_get_width(lv_obj_get_parent(this->obj)) / 2; + auto cx = lv_obj_get_width(lv_obj_get_parent(this->obj)) / 2; + auto cy = lv_obj_get_height(lv_obj_get_parent(this->obj)) / 2; + auto radius = clamp_at_most(cx, cy); auto length = lv_obj_get_style_length(this->obj, LV_PART_MAIN); auto radial_offset = lv_obj_get_style_radial_offset(this->obj, LV_PART_MAIN); if (LV_COORD_IS_PCT(radial_offset)) { radial_offset = radius * LV_COORD_GET_PCT(radial_offset) / 100; } if (LV_COORD_IS_PCT(length)) { - actual_needle_length = radius * LV_COORD_GET_PCT(length) / 100; + length = radius * LV_COORD_GET_PCT(length) / 100; } else if (length < 0) { - actual_needle_length = radius + length; - } else { - actual_needle_length = length; + length += radius; } auto x = lv_trigo_cos(this->angle_) / 32768.0f; auto y = lv_trigo_sin(this->angle_) / 32768.0f; + // radius here also represents the offset of the scale center from top left this->points_[0].x = radius + radial_offset * x; this->points_[0].y = radius + radial_offset * y; - this->points_[1].x = x * actual_needle_length + radius; - this->points_[1].y = y * actual_needle_length + radius; + this->points_[1].x = radius + x * (radial_offset + length); + this->points_[1].y = radius + y * (radial_offset + length); lv_obj_refresh_self_size(this->obj); lv_obj_invalidate(this->obj); } @@ -682,15 +682,15 @@ void lv_scale_draw_event_cb(lv_event_t *e, int16_t range_start, int16_t range_en auto *line_dsc = static_cast(lv_draw_task_get_draw_dsc(task)); int tick = line_dsc->base.id2; if (tick >= range_start && tick <= range_end) { - unsigned range = range_end - range_start; + int ratio; if (local) { + int range = range_end - range_start; tick -= range_start; + ratio = range == 0 ? 0 : (tick * 255) / range; } else { - range = lv_scale_get_total_tick_count(scale) - 1; + // total tick count is guaranteed to be at least 2. + ratio = (line_dsc->base.id1 * 255) / (lv_scale_get_total_tick_count(scale) - 1); } - if (range == 0) - range = 1; - auto ratio = (tick * 255) / range; line_dsc->color = lv_color_mix(color_end, color_start, ratio); line_dsc->width += width; } diff --git a/esphome/components/lvgl/number/__init__.py b/esphome/components/lvgl/number/__init__.py index c48e051eac..d80e93708b 100644 --- a/esphome/components/lvgl/number/__init__.py +++ b/esphome/components/lvgl/number/__init__.py @@ -12,7 +12,7 @@ from ..lvcode import ( UPDATE_EVENT, LambdaContext, ReturnStatement, - lv, + lv_obj, lvgl_static, ) from ..types import LV_EVENT, LvNumber, lvgl_ns @@ -40,7 +40,7 @@ async def to_code(config): await widget.set_property( "value", MockObj("v") * MockObj(widget.get_scale()), config[CONF_ANIMATED] ) - lv.event_send(widget.obj, API_EVENT, cg.nullptr) + lv_obj.send_event(widget.obj, API_EVENT, cg.nullptr) event_code = ( LV_EVENT.VALUE_CHANGED if not config[CONF_UPDATE_ON_RELEASE] diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bcbb193ce3..9c9504f05f 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -146,26 +146,41 @@ def point_schema(value): # All LVGL styles and their validators -STYLE_PROPS = { +BASE_PROPS = { "align": df.CHILD_ALIGNMENTS.one_of, - "arc_opa": lvalid.opacity, + "anim_duration": lvalid.lv_milliseconds, "arc_color": lvalid.lv_color, + "arc_opa": lvalid.opacity, "arc_rounded": lvalid.lv_bool, "arc_width": lvalid.pixels, - "anim_time": lvalid.lv_milliseconds, + "base_dir": df.LvConstant("LV_BASE_DIR_", "LTR", "RTL", "AUTO").one_of, "bg_color": lvalid.lv_color, "bg_grad": lv_gradient, "bg_grad_color": lvalid.lv_color, - "bg_dither_mode": df.LvConstant("LV_DITHER_", "NONE", "ORDERED", "ERR_DIFF").one_of, "bg_grad_dir": LV_GRAD_DIR.one_of, + "bg_grad_opa": lvalid.opacity, "bg_grad_stop": lvalid.stop_value, "bg_image_opa": lvalid.opacity, "bg_image_recolor": lvalid.lv_color, "bg_image_recolor_opa": lvalid.opacity, "bg_image_src": lvalid.lv_image, "bg_image_tiled": lvalid.lv_bool, + "bg_main_opa": lvalid.opacity, "bg_main_stop": lvalid.stop_value, "bg_opa": lvalid.opacity, + "blend_mode": df.LvConstant( + "LV_BLEND_MODE_", + "NORMAL", + "ADDITIVE", + "SUBTRACTIVE", + "MULTIPLY", + "DIFFERENCE", + ).one_of, + "blur_backdrop": lvalid.lv_bool, + "blur_quality": df.LvConstant( + "LV_BLUR_QUALITY_", "AUTO", "SPEED", "PRECISION" + ).one_of, + "blur_radius": lvalid.lv_positive_int, "border_color": lvalid.lv_color, "border_opa": lvalid.opacity, "border_post": lvalid.lv_bool, @@ -175,33 +190,53 @@ STYLE_PROPS = { "border_width": lvalid.lv_positive_int, "clip_corner": lvalid.lv_bool, "color_filter_opa": lvalid.opacity, + "drop_shadow_color": lvalid.lv_color, + "drop_shadow_offset_x": lvalid.lv_int, + "drop_shadow_offset_y": lvalid.lv_int, + "drop_shadow_opa": lvalid.opacity, + "drop_shadow_quality": df.LvConstant( + "LV_BLUR_QUALITY_", "AUTO", "SPEED", "PRECISION" + ).one_of, + "drop_shadow_radius": lvalid.lv_positive_int, "height": lvalid.size, + "image_opa": lvalid.opacity, "image_recolor": lvalid.lv_color, "image_recolor_opa": lvalid.opacity, + "length": lvalid.pixels_or_percent, "line_color": lvalid.lv_color, "line_dash_gap": lvalid.lv_positive_int, "line_dash_width": lvalid.lv_positive_int, "line_opa": lvalid.opacity, "line_rounded": lvalid.lv_bool, "line_width": lvalid.lv_positive_int, + "margin_bottom": lvalid.padding, + "margin_left": lvalid.padding, + "margin_right": lvalid.padding, + "margin_top": lvalid.padding, + "max_height": lvalid.pixels_or_percent, + "max_width": lvalid.pixels_or_percent, + "min_height": lvalid.pixels_or_percent, + "min_width": lvalid.pixels_or_percent, "opa": lvalid.opacity, "opa_layered": lvalid.opacity, "outline_color": lvalid.lv_color, "outline_opa": lvalid.opacity, "outline_pad": lvalid.padding, "outline_width": lvalid.pixels, - "length": lvalid.pixels_or_percent, "pad_all": lvalid.padding, "pad_bottom": lvalid.padding, "pad_left": lvalid.padding, + "pad_radial": lvalid.padding, "pad_right": lvalid.padding, "pad_top": lvalid.padding, "radial_offset": lvalid.size, + "radius": lvalid.lv_fraction, + "recolor": lvalid.lv_color, + "recolor_opa": lvalid.opacity, + "rotary_sensitivity": lvalid.lv_positive_int, "shadow_color": lvalid.lv_color, "shadow_offset_x": lvalid.lv_int, "shadow_offset_y": lvalid.lv_int, - "shadow_ofs_x": lvalid.lv_int, - "shadow_ofs_y": lvalid.lv_int, "shadow_opa": lvalid.opacity, "shadow_spread": lvalid.lv_int, "shadow_width": lvalid.lv_positive_int, @@ -216,7 +251,9 @@ STYLE_PROPS = { "text_letter_space": lvalid.lv_positive_int, "text_line_space": lvalid.lv_positive_int, "text_opa": lvalid.opacity, - "transform_angle": lvalid.lv_angle, + "text_outline_stroke_color": lvalid.lv_color, + "text_outline_stroke_opa": lvalid.opacity, + "text_outline_stroke_width": lvalid.lv_positive_int, "transform_height": lvalid.pixels_or_percent, "transform_pivot_x": lvalid.pixels_or_percent, "transform_pivot_y": lvalid.pixels_or_percent, @@ -226,20 +263,17 @@ STYLE_PROPS = { "transform_scale_y": lvalid.scale, "transform_skew_x": lvalid.lv_angle, "transform_skew_y": lvalid.lv_angle, - "transform_zoom": lvalid.scale, + "transform_width": lvalid.pixels_or_percent, + "translate_radial": lvalid.lv_int, "translate_x": lvalid.pixels_or_percent, "translate_y": lvalid.pixels_or_percent, - "max_height": lvalid.pixels_or_percent, - "max_width": lvalid.pixels_or_percent, - "min_height": lvalid.pixels_or_percent, - "min_width": lvalid.pixels_or_percent, - "radius": lvalid.lv_fraction, "width": lvalid.size, "x": lvalid.pixels_or_percent, "y": lvalid.pixels_or_percent, } STYLE_REMAP = { + "anim_time": "anim_duration", "transform_angle": "transform_rotation", "transform_zoom": "transform_scale", "zoom": "scale", @@ -249,6 +283,10 @@ STYLE_REMAP = { "r_mod": "length", } +STYLE_PROPS = BASE_PROPS | { + p: BASE_PROPS[v] for p, v in STYLE_REMAP.items() if v in BASE_PROPS +} + def remap_property(prop, record=True): """ @@ -394,6 +432,7 @@ def obj_schema(widget_type: WidgetType): return ( part_schema(widget_type.parts) .extend(ALIGN_TO_SCHEMA) + .extend({cv.Optional(df.CONF_EXT_CLICK_AREA): lvalid.pixels}) .extend(automation_schema(widget_type.w_type)) .extend( { diff --git a/esphome/components/lvgl/switch/__init__.py b/esphome/components/lvgl/switch/__init__.py index 6d10a70d85..a43851b4a3 100644 --- a/esphome/components/lvgl/switch/__init__.py +++ b/esphome/components/lvgl/switch/__init__.py @@ -13,8 +13,8 @@ from ..lvcode import ( LambdaContext, LvConditional, LvContext, - lv, lv_add, + lv_obj, lvgl_static, ) from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t, lvgl_ns @@ -39,7 +39,7 @@ async def to_code(config): widget.add_state(LV_STATE.CHECKED) cond.else_() widget.clear_state(LV_STATE.CHECKED) - lv.event_send(widget.obj, API_EVENT, cg.nullptr) + lv_obj.send_event(widget.obj, API_EVENT, cg.nullptr) control.add(switch_id.publish_state(v)) switch = cg.new_Pvariable(config[CONF_ID], await control.get_lambda()) await cg.register_component(switch, config) diff --git a/esphome/components/lvgl/text/__init__.py b/esphome/components/lvgl/text/__init__.py index eb56cdb7a7..190ecacda5 100644 --- a/esphome/components/lvgl/text/__init__.py +++ b/esphome/components/lvgl/text/__init__.py @@ -10,8 +10,8 @@ from ..lvcode import ( UPDATE_EVENT, LambdaContext, LvContext, - lv, lv_add, + lv_obj, lvgl_static, ) from ..types import LV_EVENT, LvText, lvgl_ns @@ -33,7 +33,7 @@ async def to_code(config): await wait_for_widgets() async with LambdaContext([(cg.std_string, "text_value")]) as control: await widget.set_property("text", "text_value.c_str()") - lv.event_send(widget.obj, API_EVENT, cg.nullptr) + lv_obj.send_event(widget.obj, API_EVENT, cg.nullptr) control.add(textvar.publish_state(widget.get_value())) async with LambdaContext(EVENT_ARG) as lamb: lv_add(textvar.publish_state(widget.get_value())) diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index 54309cdf89..c52d213e15 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -15,6 +15,7 @@ from .defines import ( CONF_ALIGN, CONF_ALIGN_TO, CONF_ALIGN_TO_LAMBDA_ID, + CONF_EXT_CLICK_AREA, DIRECTIONS, LV_EVENT_MAP, LV_EVENT_TRIGGERS, @@ -113,6 +114,8 @@ async def generate_align_tos(config: dict): x = align_to[CONF_X] y = align_to[CONF_Y] lv.obj_align_to(w.obj, target, align, x, y) + if ext_click_area := w.config.get(CONF_EXT_CLICK_AREA): + lv.obj_set_ext_click_area(w.obj, ext_click_area) action_id = config[CONF_ALIGN_TO_LAMBDA_ID] var = new_Pvariable(action_id, await context.get_lambda()) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index 494f811a8e..ab65a7c47d 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -56,11 +56,11 @@ from ..lv_validation import ( lv_float, lv_image, lv_int, + lv_positive_int, opacity, padding, pixels, pixels_or_percent, - pixels_or_percent_validator, requires_component, size, ) @@ -88,7 +88,10 @@ CONF_COLOR_START = "color_start" CONF_DRAW_TICKS_ON_TOP = "draw_ticks_on_top" CONF_IMAGE_ID = "image_id" CONF_INDICATORS = "indicators" +CONF_DASH_GAP = "dash_gap" +CONF_DASH_WIDTH = "dash_width" CONF_LINE_ID = "line_id" +CONF_ROUNDED = "rounded" CONF_LABEL_GAP = "label_gap" CONF_MAJOR = "major" CONF_METER = "meter" @@ -135,9 +138,12 @@ INDICATOR_LINE_SCHEMA = cv.Schema( { cv.Optional(CONF_WIDTH, default=4): cv.int_, cv.Optional(CONF_COLOR, default=0): lv_color, + cv.Optional(CONF_ROUNDED, default=True): lv_bool, + cv.Optional(CONF_DASH_GAP): lv_positive_int, + cv.Optional(CONF_DASH_WIDTH): lv_positive_int, cv.Optional(CONF_R_MOD): padding, - cv.Optional(CONF_LENGTH): pixels_or_percent_validator, - cv.Optional(CONF_RADIAL_OFFSET, 0): pixels_or_percent_validator, + cv.Optional(CONF_LENGTH): pixels_or_percent, + cv.Optional(CONF_RADIAL_OFFSET): pixels_or_percent, cv.Optional(CONF_VALUE, default=0.0): lv_float, cv.Optional(CONF_OPA, default=1.0): opacity, } @@ -249,17 +255,17 @@ SCALE_SCHEMA = cv.Schema( { cv.Optional(CONF_COUNT, default=12): cv.int_range(min=2), cv.Optional(CONF_WIDTH, default=2): cv.positive_int, - cv.Optional(CONF_LENGTH, default=10): size, - cv.Optional(CONF_RADIAL_OFFSET, default=0): size, + cv.Optional(CONF_LENGTH, default=10): cv.positive_int, + cv.Optional(CONF_RADIAL_OFFSET): cv.positive_int, cv.Optional(CONF_COLOR, default=0x808080): lv_color, cv.Optional(CONF_MAJOR): cv.Schema( { cv.Optional(CONF_STRIDE, default=3): cv.positive_int, cv.Optional(CONF_WIDTH, default=5): size, - cv.Optional(CONF_LENGTH, default="15%"): size, - cv.Optional(CONF_RADIAL_OFFSET, default=0): size, + cv.Optional(CONF_LENGTH, default=12): cv.positive_int, + cv.Optional(CONF_RADIAL_OFFSET): cv.positive_int, cv.Optional(CONF_COLOR, default=0): lv_color, - cv.Optional(CONF_LABEL_GAP, default=4): size, + cv.Optional(CONF_LABEL_GAP, default=4): cv.int_, } ), } @@ -466,11 +472,15 @@ class MeterType(WidgetType): CONF_OPA: v[CONF_OPA], CONF_LINE_WIDTH: v[CONF_WIDTH], "line_color": v[CONF_COLOR], - "line_rounded": True, + "line_rounded": v[CONF_ROUNDED], CONF_ALIGN: CHILD_ALIGNMENTS.TOP_LEFT, CONF_LENGTH: length, - CONF_RADIAL_OFFSET: v[CONF_RADIAL_OFFSET], } + if radial_offset := v.get(CONF_RADIAL_OFFSET): + props[CONF_RADIAL_OFFSET] = radial_offset + for option in (CONF_DASH_WIDTH, CONF_DASH_GAP): + if option in v: + props["line_" + option] = v[option] lw = await widget_to_code(props, line_indicator_type, scale_var) await set_indicator_values(lw, v) @@ -478,10 +488,8 @@ class MeterType(WidgetType): add_lv_use(CONF_IMAGE) src = v[CONF_SRC] src_data = get_image_metadata(src.id) - pivot_x = await pixels.process(v[CONF_PIVOT_X]) - pivot_y = await pixels.process( - v.get(CONF_PIVOT_Y, src_data.height // 2) - ) + pivot_x = v[CONF_PIVOT_X] + pivot_y = v.get(CONF_PIVOT_Y, src_data.height // 2) props = { CONF_X: src_data.width // 2 - pivot_x, "transform_pivot_x": pivot_x, @@ -511,11 +519,12 @@ class MeterType(WidgetType): lv_obj.set_style_line_width( scale_var, await size.process(ticks[CONF_WIDTH]), LV_PART.ITEMS ) - lv_obj.set_style_radial_offset( - scale_var, - await size.process(ticks[CONF_RADIAL_OFFSET]), - LV_PART.ITEMS, - ) + if radial_offset := ticks.get(CONF_RADIAL_OFFSET): + lv_obj.set_style_radial_offset( + scale_var, + -radial_offset, + LV_PART.ITEMS, + ) lv_obj.set_style_line_color( scale_var, await lv_color.process(ticks[CONF_COLOR]), @@ -536,11 +545,12 @@ class MeterType(WidgetType): await size.process(major[CONF_LENGTH]), LV_PART.INDICATOR, ) - lv_obj.set_style_radial_offset( - scale_var, - await size.process(ticks[CONF_RADIAL_OFFSET]), - LV_PART.INDICATOR, - ) + if radial_offset := major.get(CONF_RADIAL_OFFSET): + lv_obj.set_style_radial_offset( + scale_var, + -radial_offset, + LV_PART.INDICATOR, + ) lv_obj.set_style_line_width( scale_var, await size.process(major[CONF_WIDTH]), @@ -553,12 +563,9 @@ class MeterType(WidgetType): ) # Set label gap (padding) - label_gap = await size.process(major[CONF_LABEL_GAP]) - if isinstance(label_gap, int): - label_gap -= DEFAULT_LABEL_GAP lv_obj.set_style_pad_radial( scale_var, - label_gap, + major[CONF_LABEL_GAP] - DEFAULT_LABEL_GAP, LV_PART.INDICATOR, ) else: diff --git a/esphome/components/lvgl/widgets/tileview.py b/esphome/components/lvgl/widgets/tileview.py index 8e9d95f349..4657d628de 100644 --- a/esphome/components/lvgl/widgets/tileview.py +++ b/esphome/components/lvgl/widgets/tileview.py @@ -129,6 +129,6 @@ async def tileview_select(config, action_id, template_arg, args): lv.tileview_set_tile_by_index( widgets[0].obj, column, row, literal(config[CONF_ANIMATED]) ) - lv.event_send(w.obj, LV_EVENT.VALUE_CHANGED, cg.nullptr) + lv_obj.send_event(w.obj, LV_EVENT.VALUE_CHANGED, cg.nullptr) return await action_to_code(widgets, do_select, action_id, template_arg, args) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 821476a72b..b8c9a1809e 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -232,7 +232,7 @@ lvgl: - roller: id: lv_roller visible_row_count: 2 - anim_time: 500ms + anim_duration: 500ms options: - Nov - Dec @@ -317,20 +317,27 @@ lvgl: align: top_left - container: align: center + anim_duration: 1s arc_opa: COVER arc_color: 0xFF0000 arc_rounded: false arc_width: 3 - anim_time: 1s + base_dir: auto bg_color: light_blue bg_grad_color: light_blue bg_grad_dir: hor + bg_grad_opa: cover bg_grad_stop: 128 bg_image_opa: transp bg_image_recolor: light_blue bg_image_recolor_opa: 50% + bg_main_opa: cover bg_main_stop: 0 bg_opa: 20% + blend_mode: normal + blur_backdrop: false + blur_quality: auto + blur_radius: 0 border_color: 0x00FF00 border_opa: cover border_post: true @@ -338,7 +345,15 @@ lvgl: border_width: 4 clip_corner: false color_filter_opa: transp + drop_shadow_color: 0x000000 + drop_shadow_offset_x: 5 + drop_shadow_offset_y: 5 + drop_shadow_opa: cover + drop_shadow_quality: precision + drop_shadow_radius: 10 + ext_click_area: 100px height: 50% + image_opa: cover image_recolor: light_blue image_recolor_opa: cover line_width: 10 @@ -346,6 +361,10 @@ lvgl: line_dash_gap: 10 line_rounded: false line_color: light_blue + margin_bottom: 4 + margin_left: 4 + margin_right: 4 + margin_top: 4 opa: cover opa_layered: cover outline_color: light_blue @@ -355,8 +374,12 @@ lvgl: pad_all: 10px pad_bottom: 10px pad_left: 10px + pad_radial: 0 pad_right: 10px pad_top: 10px + recolor: 0xFF0000 + recolor_opa: transp + rotary_sensitivity: 256 shadow_color: light_blue shadow_opa: cover shadow_spread: 5 @@ -368,6 +391,9 @@ lvgl: text_letter_space: 4 text_line_space: 4 text_opa: cover + text_outline_stroke_color: 0x000000 + text_outline_stroke_opa: cover + text_outline_stroke_width: 2 transform_rotation: 90 transform_height: 100 transform_pivot_x: 50% @@ -377,8 +403,10 @@ lvgl: transform_scale_y: 0.8 transform_skew_x: 10 transform_skew_y: 20 + transform_width: 100 shadow_offset_x: 3 shadow_offset_y: 3 + translate_radial: 0 translate_x: 10 translate_y: 10 max_height: 100 @@ -1053,7 +1081,7 @@ lvgl: - ticks: width: 1 count: 61 - length: 20% + length: 20 radial_offset: 5 color: 0xFFFFFF major: From 2cb987095da9ca82aaa6f4412184f8cda9fc8090 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 31 Mar 2026 13:48:16 -0700 Subject: [PATCH 114/160] [modbus] Share helper functions across modbus components - part B (#14172) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/modbus/modbus_helpers.cpp | 139 ++++++++++++++++++ esphome/components/modbus/modbus_helpers.h | 101 +++++++++++++ .../binary_sensor/modbus_binarysensor.cpp | 4 +- .../modbus_controller/modbus_controller.cpp | 134 +---------------- .../modbus_controller/modbus_controller.h | 109 ++++---------- .../number/modbus_number.cpp | 2 +- .../output/modbus_output.cpp | 2 +- .../select/modbus_select.cpp | 4 +- .../switch/modbus_switch.cpp | 4 +- 9 files changed, 277 insertions(+), 222 deletions(-) create mode 100644 esphome/components/modbus/modbus_helpers.cpp diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp new file mode 100644 index 0000000000..77190b2846 --- /dev/null +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -0,0 +1,139 @@ +#include "modbus_helpers.h" +#include "esphome/core/log.h" + +namespace esphome::modbus::helpers { + +static const char *const TAG = "modbus_helpers"; + +void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { + switch (value_type) { + case SensorValueType::U_WORD: + case SensorValueType::S_WORD: + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_DWORD: + case SensorValueType::S_DWORD: + case SensorValueType::FP32: + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_DWORD_R: + case SensorValueType::S_DWORD_R: + case SensorValueType::FP32_R: + data.push_back(value & 0xFFFF); + data.push_back((value & 0xFFFF0000) >> 16); + break; + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + data.push_back((value & 0xFFFF000000000000) >> 48); + data.push_back((value & 0xFFFF00000000) >> 32); + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: + data.push_back(value & 0xFFFF); + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back((value & 0xFFFF00000000) >> 32); + data.push_back((value & 0xFFFF000000000000) >> 48); + break; + default: + ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); + break; + } +} + +int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, + uint32_t bitmask) { + int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits + + if (offset > data.size()) { + ESP_LOGE(TAG, "not enough data for value"); + return value; + } + + size_t size = data.size() - offset; + bool error = false; + switch (sensor_value_type) { + case SensorValueType::U_WORD: + if (size >= 2) { + value = mask_and_shift_by_rightbit(get_data(data, offset), + bitmask); // default is 0xFFFF ; + } else { + error = true; + } + break; + case SensorValueType::U_DWORD: + case SensorValueType::FP32: + if (size >= 4) { + value = get_data(data, offset); + value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); + } else { + error = true; + } + break; + case SensorValueType::U_DWORD_R: + case SensorValueType::FP32_R: + if (size >= 4) { + value = get_data(data, offset); + value = static_cast(value & 0xFFFF) << 16 | (value & 0xFFFF0000) >> 16; + value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); + } else { + error = true; + } + break; + case SensorValueType::S_WORD: + if (size >= 2) { + value = mask_and_shift_by_rightbit(get_data(data, offset), + bitmask); // default is 0xFFFF ; + } else { + error = true; + } + break; + case SensorValueType::S_DWORD: + if (size >= 4) { + value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); + } else { + error = true; + } + break; + case SensorValueType::S_DWORD_R: { + if (size >= 4) { + value = get_data(data, offset); + // Currently the high word is at the low position + // the sign bit is therefore at low before the switch + uint32_t sign_bit = (value & 0x8000) << 16; + value = mask_and_shift_by_rightbit( + static_cast(((value & 0x7FFF) << 16 | (value & 0xFFFF0000) >> 16) | sign_bit), bitmask); + } else { + error = true; + } + } break; + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + // Ignore bitmask for QWORD + if (size >= 8) { + value = get_data(data, offset); + } else { + error = true; + } + break; + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: { + // Ignore bitmask for QWORD + if (size >= 8) { + uint64_t tmp = get_data(data, offset); + value = (tmp << 48) | (tmp >> 48) | ((tmp & 0xFFFF0000) << 16) | ((tmp >> 16) & 0xFFFF0000); + } else { + error = true; + } + } break; + case SensorValueType::RAW: + default: + break; + } + if (error) + ESP_LOGE(TAG, "not enough data for value"); + return value; +} +} // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 9f78de1c21..84897bcad3 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include "esphome/core/helpers.h" #include "esphome/components/modbus/modbus_definitions.h" @@ -103,4 +105,103 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { return static_cast(dword_from_hex_str(value, pos)) << 32 | dword_from_hex_str(value, pos + 4); } +// Extract data from modbus response buffer +/** Extract data from modbus response buffer + * @param T one of supported integer data types int_8,int_16,int_32,int_64 + * @param data modbus response buffer (uint8_t) + * @param buffer_offset offset in bytes. + * @return value of type T extracted from buffer + */ +template T get_data(const std::vector &data, size_t buffer_offset) { + if (sizeof(T) == sizeof(uint8_t)) { + return T(data[buffer_offset]); + } + if (sizeof(T) == sizeof(uint16_t)) { + return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0)); + } + + if (sizeof(T) == sizeof(uint32_t)) { + return static_cast(get_data(data, buffer_offset)) << 16 | + static_cast(get_data(data, buffer_offset + 2)); + } + + if (sizeof(T) == sizeof(uint64_t)) { + return static_cast(get_data(data, buffer_offset)) << 32 | + (static_cast(get_data(data, buffer_offset + 4))); + } + + static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) || + sizeof(T) == sizeof(uint64_t), + "Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported."); + + return T{}; +} + +/** Extract coil data from modbus response buffer + * Responses for coil are packed into bytes . + * coil 3 is bit 3 of the first response byte + * coil 9 is bit 2 of the second response byte + * @param coil number of the cil + * @param data modbus response buffer (uint8_t) + * @return content of coil register + */ +inline bool coil_from_vector(int coil, const std::vector &data) { + auto data_byte = coil / 8; + return (data[data_byte] & (1 << (coil % 8))) > 0; +} + +/** Extract bits from value and shift right according to the bitmask + * if the bitmask is 0x00F0 we want the values frrom bit 5 - 8. + * the result is then shifted right by the position if the first right set bit in the mask + * Useful for modbus data where more than one value is packed in a 16 bit register + * Example: on Epever the "Length of night" register 0x9065 encodes values of the whole night length of time as + * D15 - D8 = hour, D7 - D0 = minute + * To get the hours use mask 0xFF00 and 0x00FF for the minute + * @param data an integral value between 16 aand 32 bits, + * @param bitmask the bitmask to apply + */ +template N mask_and_shift_by_rightbit(N data, uint32_t mask) { + auto result = (mask & data); + if (result == 0 || mask == 0xFFFFFFFF) { + return result; + } + for (size_t pos = 0; pos < sizeof(N) << 3; pos++) { + if (pos < 32 && (mask & (1UL << pos)) != 0) + return result >> pos; + } + return 0; +} + +/** Convert float value to vector suitable for sending + * @param data target for payload + * @param value float value to convert + * @param value_type defines if 16/32 or FP32 is used + * @return vector containing the modbus register words in correct order + */ +void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type); + +/** Convert vector response payload to number. + * @param data payload with the data to convert + * @param sensor_value_type defines if 16/32/64 bits or FP32 is used + * @param offset offset to the data in data + * @param bitmask bitmask used for masking and shifting + * @return 64-bit number of the payload + */ +int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, + uint32_t bitmask); + +inline std::vector float_to_payload(float value, SensorValueType value_type) { + int64_t val; + + if (value_type_is_float(value_type)) { + val = bit_cast(value); + } else { + val = llroundf(value); + } + + std::vector data; + number_to_payload(data, val, value_type); + return data; +} + } // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index c3eb3d4411..1ea3041b4d 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -15,10 +15,10 @@ void ModbusBinarySensor::parse_and_publish(const std::vector &data) { case ModbusRegisterType::DISCRETE_INPUT: case ModbusRegisterType::COIL: // offset for coil is the actual number of the coil not the byte offset - value = coil_from_vector(this->offset, data); + value = modbus::helpers::coil_from_vector(this->offset, data); break; default: - value = get_data(data, this->offset) & this->bitmask; + value = modbus::helpers::get_data(data, this->offset) & this->bitmask; break; } // Is there a lambda registered diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 38eaea2d1c..3c4ceaf62d 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -140,7 +140,7 @@ void ModbusController::on_modbus_read_registers(uint8_t function_code, uint16_t std::vector payload; payload.reserve(server_register->register_count * 2); - number_to_payload(payload, value, server_register->value_type); + modbus::helpers::number_to_payload(payload, value, server_register->value_type); sixteen_bit_response.insert(sixteen_bit_response.end(), payload.cbegin(), payload.cend()); current_address += server_register->register_count; found = true; @@ -258,7 +258,7 @@ void ModbusController::on_modbus_write_registers(uint8_t function_code, const st // Actually write to the registers: if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { - int64_t number = payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); + int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); return server_register->write_lambda(number); })) { this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); @@ -517,7 +517,8 @@ void ModbusController::loop() { void ModbusController::on_write_register_response(ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { - ESP_LOGV(TAG, "Command ACK 0x%X %d ", get_data(data, 0), get_data(data, 1)); + ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data(data, 0), + modbus::helpers::get_data(data, 1)); } void ModbusController::dump_sensors_() { @@ -710,132 +711,5 @@ bool ModbusCommandItem::is_equal(const ModbusCommandItem &other) { other.register_type == this->register_type && other.function_code == this->function_code; } -void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { - switch (value_type) { - case SensorValueType::U_WORD: - case SensorValueType::S_WORD: - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_DWORD: - case SensorValueType::S_DWORD: - case SensorValueType::FP32: - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_DWORD_R: - case SensorValueType::S_DWORD_R: - case SensorValueType::FP32_R: - data.push_back(value & 0xFFFF); - data.push_back((value & 0xFFFF0000) >> 16); - break; - case SensorValueType::U_QWORD: - case SensorValueType::S_QWORD: - data.push_back((value & 0xFFFF000000000000) >> 48); - data.push_back((value & 0xFFFF00000000) >> 32); - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_QWORD_R: - case SensorValueType::S_QWORD_R: - data.push_back(value & 0xFFFF); - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back((value & 0xFFFF00000000) >> 32); - data.push_back((value & 0xFFFF000000000000) >> 48); - break; - default: - ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversation: %d", - static_cast(value_type)); - break; - } -} - -int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { - int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits - - size_t size = data.size() - offset; - bool error = false; - switch (sensor_value_type) { - case SensorValueType::U_WORD: - if (size >= 2) { - value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); // default is 0xFFFF ; - } else { - error = true; - } - break; - case SensorValueType::U_DWORD: - case SensorValueType::FP32: - if (size >= 4) { - value = get_data(data, offset); - value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); - } else { - error = true; - } - break; - case SensorValueType::U_DWORD_R: - case SensorValueType::FP32_R: - if (size >= 4) { - value = get_data(data, offset); - value = static_cast(value & 0xFFFF) << 16 | (value & 0xFFFF0000) >> 16; - value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); - } else { - error = true; - } - break; - case SensorValueType::S_WORD: - if (size >= 2) { - value = mask_and_shift_by_rightbit(get_data(data, offset), - bitmask); // default is 0xFFFF ; - } else { - error = true; - } - break; - case SensorValueType::S_DWORD: - if (size >= 4) { - value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); - } else { - error = true; - } - break; - case SensorValueType::S_DWORD_R: { - if (size >= 4) { - value = get_data(data, offset); - // Currently the high word is at the low position - // the sign bit is therefore at low before the switch - uint32_t sign_bit = (value & 0x8000) << 16; - value = mask_and_shift_by_rightbit( - static_cast(((value & 0x7FFF) << 16 | (value & 0xFFFF0000) >> 16) | sign_bit), bitmask); - } else { - error = true; - } - } break; - case SensorValueType::U_QWORD: - case SensorValueType::S_QWORD: - // Ignore bitmask for QWORD - if (size >= 8) { - value = get_data(data, offset); - } else { - error = true; - } - break; - case SensorValueType::U_QWORD_R: - case SensorValueType::S_QWORD_R: { - // Ignore bitmask for QWORD - if (size >= 8) { - uint64_t tmp = get_data(data, offset); - value = (tmp << 48) | (tmp >> 48) | ((tmp & 0xFFFF0000) << 16) | ((tmp >> 16) & 0xFFFF0000); - } else { - error = true; - } - } break; - case SensorValueType::RAW: - default: - break; - } - if (error) - ESP_LOGE(TAG, "not enough data for value"); - return value; -} - } // namespace modbus_controller } // namespace esphome diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 438eb12c2a..6c6c748b73 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -59,83 +59,38 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { return modbus::helpers::qword_from_hex_str(value, pos); } -// Extract data from modbus response buffer -/** Extract data from modbus response buffer - * @param T one of supported integer data types int_8,int_16,int_32,int_64 - * @param data modbus response buffer (uint8_t) - * @param buffer_offset offset in bytes. - * @return value of type T extracted from buffer - */ -template T get_data(const std::vector &data, size_t buffer_offset) { - if (sizeof(T) == sizeof(uint8_t)) { - return T(data[buffer_offset]); - } - if (sizeof(T) == sizeof(uint16_t)) { - return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0)); - } - - if (sizeof(T) == sizeof(uint32_t)) { - return get_data(data, buffer_offset) << 16 | get_data(data, (buffer_offset + 2)); - } - - if (sizeof(T) == sizeof(uint64_t)) { - return static_cast(get_data(data, buffer_offset)) << 32 | - (static_cast(get_data(data, buffer_offset + 4))); - } +template +ESPDEPRECATED("Use modbus::helpers::get_data() instead. Removed in 2026.10.0", "2026.4.0") +T get_data(const std::vector &data, size_t buffer_offset) { + return modbus::helpers::get_data(data, buffer_offset); } -/** Extract coil data from modbus response buffer - * Responses for coil are packed into bytes . - * coil 3 is bit 3 of the first response byte - * coil 9 is bit 2 of the second response byte - * @param coil number of the cil - * @param data modbus response buffer (uint8_t) - * @return content of coil register - */ +ESPDEPRECATED("Use modbus::helpers::coil_from_vector() instead. Removed in 2026.10.0", "2026.4.0") inline bool coil_from_vector(int coil, const std::vector &data) { - auto data_byte = coil / 8; - return (data[data_byte] & (1 << (coil % 8))) > 0; + return modbus::helpers::coil_from_vector(coil, data); } -/** Extract bits from value and shift right according to the bitmask - * if the bitmask is 0x00F0 we want the values frrom bit 5 - 8. - * the result is then shifted right by the position if the first right set bit in the mask - * Useful for modbus data where more than one value is packed in a 16 bit register - * Example: on Epever the "Length of night" register 0x9065 encodes values of the whole night length of time as - * D15 - D8 = hour, D7 - D0 = minute - * To get the hours use mask 0xFF00 and 0x00FF for the minute - * @param data an integral value between 16 aand 32 bits, - * @param bitmask the bitmask to apply - */ -template N mask_and_shift_by_rightbit(N data, uint32_t mask) { - auto result = (mask & data); - if (result == 0 || mask == 0xFFFFFFFF) { - return result; - } - for (size_t pos = 0; pos < sizeof(N) << 3; pos++) { - if ((mask & (1UL << pos)) != 0) - return result >> pos; - } - return 0; +template +ESPDEPRECATED("Use modbus::helpers::mask_and_shift_by_rightbit() instead. Removed in 2026.10.0", "2026.4.0") +N mask_and_shift_by_rightbit(N data, uint32_t mask) { + return modbus::helpers::mask_and_shift_by_rightbit(data, mask); } -/** Convert float value to vector suitable for sending - * @param data target for payload - * @param value float value to convert - * @param value_type defines if 16/32 or FP32 is used - * @return vector containing the modbus register words in correct order - */ -void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type); +ESPDEPRECATED("Use modbus::helpers::number_to_payload() instead. Removed in 2026.10.0", "2026.4.0") +inline void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { + modbus::helpers::number_to_payload(data, value, value_type); +} -/** Convert vector response payload to number. - * @param data payload with the data to convert - * @param sensor_value_type defines if 16/32/64 bits or FP32 is used - * @param offset offset to the data in data - * @param bitmask bitmask used for masking and shifting - * @return 64-bit number of the payload - */ -int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask); +ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0") +inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, + uint32_t bitmask) { + return modbus::helpers::payload_to_number(data, sensor_value_type, offset, bitmask); +} + +ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") +inline std::vector float_to_payload(float value, SensorValueType value_type) { + return modbus::helpers::float_to_payload(value, value_type); +} class ModbusController; @@ -517,7 +472,7 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { * @return float value of data */ inline float payload_to_float(const std::vector &data, const SensorItem &item) { - int64_t number = payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask); + int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask); float float_value; if (modbus::helpers::value_type_is_float(item.sensor_value_type)) { @@ -529,19 +484,5 @@ inline float payload_to_float(const std::vector &data, const SensorItem return float_value; } -inline std::vector float_to_payload(float value, SensorValueType value_type) { - int64_t val; - - if (modbus::helpers::value_type_is_float(value_type)) { - val = bit_cast(value); - } else { - val = llroundf(value); - } - - std::vector data; - number_to_payload(data, val, value_type); - return data; -} - } // namespace modbus_controller } // namespace esphome diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index 4a3ec1fc41..ed5d91ec5b 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -62,7 +62,7 @@ void ModbusNumber::control(float value) { this->parent_->on_write_register_response(write_cmd.register_type, this->start_address, data); }); } else { - data = float_to_payload(write_value, this->sensor_value_type); + data = modbus::helpers::float_to_payload(write_value, this->sensor_value_type); ESP_LOGD(TAG, "Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)", diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index f02d9397ca..e7f1a39716 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -34,7 +34,7 @@ void ModbusFloatOutput::write_state(float value) { } // lambda didn't set payload if (data.empty()) { - data = float_to_payload(value, this->sensor_value_type); + data = modbus::helpers::float_to_payload(value, this->sensor_value_type); } ESP_LOGD(TAG, "Updating register: start address=0x%X register count=%d new value=%.02f (val=%.02f)", diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index e2a54d3f60..2cff7e89ee 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -9,7 +9,7 @@ static const char *const TAG = "modbus_controller.select"; void ModbusSelect::dump_config() { LOG_SELECT(TAG, "Modbus Controller Select", this); } void ModbusSelect::parse_and_publish(const std::vector &data) { - int64_t value = payload_to_number(data, this->sensor_value_type, this->offset, this->bitmask); + int64_t value = modbus::helpers::payload_to_number(data, this->sensor_value_type, this->offset, this->bitmask); ESP_LOGD(TAG, "New select value %lld from payload", value); @@ -61,7 +61,7 @@ void ModbusSelect::control(size_t index) { } if (data.empty()) { - number_to_payload(data, *mapval, this->sensor_value_type); + modbus::helpers::number_to_payload(data, *mapval, this->sensor_value_type); } else { ESP_LOGV(TAG, "Using payload from write lambda"); } diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 68aa37c9ed..dbaff04cc6 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -33,10 +33,10 @@ void ModbusSwitch::parse_and_publish(const std::vector &data) { case ModbusRegisterType::DISCRETE_INPUT: case ModbusRegisterType::COIL: // offset for coil is the actual number of the coil not the byte offset - value = coil_from_vector(this->offset, data); + value = modbus::helpers::coil_from_vector(this->offset, data); break; default: - value = get_data(data, this->offset) & this->bitmask; + value = modbus::helpers::get_data(data, this->offset) & this->bitmask; break; } From 64e836f9c8da7cb68ade3cb430f9dbb7b4cecc9b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:49:17 -1000 Subject: [PATCH 115/160] Bump CodSpeedHQ/action from 4.12.1 to 4.13.0 (#15340) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab7a750388..71703652e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -339,7 +339,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@1c8ae4843586d3ba879736b7f6b7b0c990757fab # v4 + uses: CodSpeedHQ/action@d872884a306dd4853acf0f584f4b706cf0cc72a2 # v4 with: run: ${{ steps.build.outputs.binary }} mode: simulation From 2064eef273c878191cd29799329c049693fa60d4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:53:12 -0400 Subject: [PATCH 116/160] [esp32_hosted] Guard against empty firmware URL in perform() (#15338) --- .../components/esp32_hosted/update/esp32_hosted_update.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index dcd6e643c2..af35d32888 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -448,6 +448,13 @@ void Esp32HostedUpdate::perform(bool force) { return; } +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE + if (this->firmware_url_.empty()) { + ESP_LOGW(TAG, "No firmware URL available, run check first"); + return; + } +#endif + update::UpdateState prev_state = this->state_; this->state_ = update::UPDATE_STATE_INSTALLING; this->update_info_.has_progress = false; From 66b6d36a260dd1900c1786f9edc98c47157c8255 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:04:10 +1000 Subject: [PATCH 117/160] [lvgl] Fixes #3 (#15304) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 10 ++----- esphome/components/lvgl/defines.py | 4 --- esphome/components/lvgl/styles.py | 31 +++------------------ esphome/components/lvgl/widgets/__init__.py | 7 +++-- esphome/components/lvgl/widgets/canvas.py | 2 -- esphome/components/lvgl/widgets/keyboard.py | 26 +++++++++++++---- esphome/components/lvgl/widgets/label.py | 2 +- esphome/components/lvgl/widgets/line.py | 19 ++++++------- esphome/components/lvgl/widgets/msgbox.py | 8 ++++-- esphome/components/lvgl/widgets/qrcode.py | 3 +- esphome/components/lvgl/widgets/tabview.py | 4 +-- 11 files changed, 48 insertions(+), 68 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6377183ef4..736fba759f 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -380,7 +380,8 @@ async def to_code(configs): # This must be done after all widgets are created for comp in helpers.lvgl_components_required: cg.add_define(f"USE_LVGL_{comp.upper()}") - lv_image_formats = df.get_color_formats().copy() + # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending + lv_image_formats = {"RGB565", "ARGB8888"} if { "transform_rotation", "transform_scale", @@ -388,10 +389,6 @@ async def to_code(configs): "transform_scale_y", } & styles_used: df.add_define("LV_COLOR_SCREEN_TRANSP", "1") - lv_image_formats.add("ARGB8888") - lv_image_formats.add( - "RGB565" - ) # Currently always need RGB565 for the display buffer for use in helpers.lv_uses: df.add_define(f"LV_USE_{use.upper()}") cg.add_define(f"USE_LVGL_{use.upper()}") @@ -401,9 +398,6 @@ async def to_code(configs): metadata = get_image_metadata(image_id.id) image_type = IMAGE_TYPE[metadata.image_type] transparent = metadata.transparency != CONF_OPAQUE - if transparent: - # Internal draw layer will use ARGB8888 - lv_image_formats.add("ARGB8888") if image_type == ImageBinary: lv_image_formats.add("I1") if image_type == ImageGrayscale: diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index de5835d7a6..dd51a2f519 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -52,10 +52,6 @@ def get_remapped_uses(): return get_data(KEY_REMAPPED_USES, set()) -def get_color_formats(): - return get_data(KEY_COLOR_FORMATS, set()) - - def add_warning(msg: str): get_warnings().add(msg) diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 6f43e78f90..793290de73 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -4,26 +4,12 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import ID -from .defines import ( - CONF_STYLE_DEFINITIONS, - CONF_THEME, - CONF_TOP_LAYER, - LValidator, - literal, -) +from .defines import CONF_STYLE_DEFINITIONS, CONF_THEME, LValidator, literal from .helpers import add_lv_use -from .lvcode import LambdaContext, LocalVariable, lv +from .lvcode import LambdaContext, lv from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, remap_property -from .types import ObjUpdateAction, lv_obj_t, lv_style_t -from .widgets import ( - Widget, - add_widgets, - collect_parts, - set_obj_properties, - theme_widget_map, - wait_for_widgets, -) -from .widgets.obj import obj_spec +from .types import ObjUpdateAction, lv_style_t +from .widgets import collect_parts, theme_widget_map, wait_for_widgets def has_style_props(config) -> bool: @@ -112,12 +98,3 @@ async def theme_to_code(config): for state, props in states.items() } theme_widget_map[w_name] = styles - - -async def add_top_layer(lv_component, config): - top_layer = lv.disp_get_layer_top(lv_component.var.get_disp()) - if top_conf := config.get(CONF_TOP_LAYER): - with LocalVariable("top_layer", lv_obj_t, top_layer) as top_layer_obj: - top_w = Widget(top_layer_obj, obj_spec, top_conf) - await set_obj_properties(top_w, top_conf) - await add_widgets(top_w, top_conf) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index b383196963..0ac4062106 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,5 +1,4 @@ import sys -from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.automation import register_action @@ -405,7 +404,11 @@ class Widget: # Map of widgets to their config, used for trigger generation -widget_map: dict[Any, Widget] = {} +widget_map: dict[ID, Widget] = {} + + +def is_widget_completed(name: ID) -> bool: + return name in widget_map class LvScrActType(WidgetType): diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 0e40d0dfbe..f12766bae1 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -42,7 +42,6 @@ from ..defines import ( CONF_SRC, CONF_START_ANGLE, addr, - get_color_formats, literal, ) from ..lv_validation import ( @@ -99,7 +98,6 @@ class CanvasType(WidgetType): # RGB565 is 16-bit (2 bytes per pixel), ARGB8888 is 32-bit (4 bytes per pixel) if config[CONF_TRANSPARENT]: color_format = "LV_COLOR_FORMAT_ARGB8888" - get_color_formats().add("ARGB8888") else: color_format = "LV_COLOR_FORMAT_NATIVE" diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index d4a71078d0..029ca5f684 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -1,12 +1,15 @@ from esphome.components.key_provider import KeyProvider import esphome.config_validation as cv from esphome.const import CONF_ITEMS, CONF_MODE +from esphome.core import CORE from esphome.cpp_types import std_string +from .. import LvContext from ..defines import CONF_MAIN, KEYBOARD_MODES, literal -from ..helpers import add_lv_use, lvgl_components_required +from ..helpers import lvgl_components_required from ..types import LvCompound, LvType -from . import Widget, WidgetType, get_widgets +from . import Widget, WidgetType, get_widgets, is_widget_completed +from .buttonmatrix import CONF_BUTTONMATRIX from .textarea import CONF_TEXTAREA, lv_textarea_t CONF_KEYBOARD = "keyboard" @@ -41,16 +44,27 @@ class KeyboardType(WidgetType): ) def get_uses(self): - return CONF_KEYBOARD, CONF_TEXTAREA + return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX async def to_code(self, w: Widget, config: dict): lvgl_components_required.add("KEY_LISTENER") lvgl_components_required.add(CONF_KEYBOARD) - add_lv_use("btnmatrix") if mode := config.get(CONF_MODE): await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode)) - if ta := await get_widgets(config, CONF_TEXTAREA): - await w.set_property(CONF_TEXTAREA, ta[0].obj) + if textarea := config.get(CONF_TEXTAREA): + # If a textarea is configured, it must be generated before the keyboard can attach it. + # If not yet configured, defer the attachment code. + + async def add_textarea(): + async with LvContext(): + await w.set_property( + CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj + ) + + if is_widget_completed(textarea): + await add_textarea() + else: + CORE.add_job(add_textarea) keyboard_spec = KeyboardType() diff --git a/esphome/components/lvgl/widgets/label.py b/esphome/components/lvgl/widgets/label.py index bb5900b8c9..5ac92f2717 100644 --- a/esphome/components/lvgl/widgets/label.py +++ b/esphome/components/lvgl/widgets/label.py @@ -35,7 +35,7 @@ class LabelType(WidgetType): if (value := config.get(CONF_TEXT)) is not None: await w.set_property(CONF_TEXT, await lv_text.process(value)) await w.set_property(CONF_LONG_MODE, config) - await w.set_property(CONF_RECOLOR, config) + await w.set_property(CONF_RECOLOR, config, processor=lv_bool) label_spec = LabelType() diff --git a/esphome/components/lvgl/widgets/line.py b/esphome/components/lvgl/widgets/line.py index a9b202163f..3112cc28d0 100644 --- a/esphome/components/lvgl/widgets/line.py +++ b/esphome/components/lvgl/widgets/line.py @@ -17,11 +17,6 @@ lv_point_t = cg.global_ns.struct("lv_point_t") lv_point_precise_t = cg.global_ns.struct("lv_point_precise_t") -LINE_SCHEMA = { - cv.Required(CONF_POINTS): cv.ensure_list(point_schema), -} - - async def process_coord(coord): if isinstance(coord, Lambda): return call_lambda(await cg.process_lambda(coord, [], return_type=lv_coord_t)) @@ -34,15 +29,17 @@ class LineType(WidgetType): CONF_LINE, LvType("LvLineType", parents=(LvCompound,)), (CONF_MAIN,), - LINE_SCHEMA, + schema={cv.Required(CONF_POINTS): cv.ensure_list(point_schema)}, + modify_schema={cv.Optional(CONF_POINTS): cv.ensure_list(point_schema)}, ) async def to_code(self, w: Widget, config): - points = [ - [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] - for p in config[CONF_POINTS] - ] - lv_add(w.var.set_points(points)) + if CONF_POINTS in config: + points = [ + [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] + for p in config[CONF_POINTS] + ] + lv_add(w.var.set_points(points)) line_spec = LineType() diff --git a/esphome/components/lvgl/widgets/msgbox.py b/esphome/components/lvgl/widgets/msgbox.py index af27ee7553..d0e6bfa3a2 100644 --- a/esphome/components/lvgl/widgets/msgbox.py +++ b/esphome/components/lvgl/widgets/msgbox.py @@ -33,6 +33,7 @@ from ..styles import LVStyle from ..types import LV_EVENT, lv_obj_t from . import Widget, WidgetType, add_widgets, set_obj_properties, widget_to_code from .button import button_spec, lv_button_t +from .img import CONF_IMAGE from .label import CONF_LABEL from .obj import obj_spec @@ -41,7 +42,7 @@ CONF_MSGBOX = "msgbox" OUTER_STYLE = LVStyle( "msgbox_outer", { - "bg_opa": 128, + "bg_opa": 0.5, "bg_color": "black", "border_width": 0, "pad_all": 0, @@ -119,6 +120,7 @@ async def msgbox_to_code(top_layer, conf): CONF_BUTTON, CONF_LABEL, CONF_MSGBOX, + CONF_IMAGE, *button_spec.get_uses(), ) if CONF_BUTTON_STYLE in conf: @@ -156,7 +158,7 @@ async def msgbox_to_code(top_layer, conf): with LocalVariable( "close_btn_", lv_obj_t, lv_expr.msgbox_add_close_button(msgbox) ) as close_btn: - lv_obj.remove_event_cb(close_btn, nullptr) + lv_obj.remove_event(close_btn, 0) lv_obj.add_event_cb( close_btn, await close_action.get_lambda(), @@ -170,6 +172,6 @@ async def msgbox_to_code(top_layer, conf): async def msgboxes_to_code(lv_component, config): - top_layer = lv.disp_get_layer_top(lv_component.get_disp()) + top_layer = lv_expr.disp_get_layer_top(lv_component.get_disp()) for conf in config.get(CONF_MSGBOXES, ()): await msgbox_to_code(top_layer, conf) diff --git a/esphome/components/lvgl/widgets/qrcode.py b/esphome/components/lvgl/widgets/qrcode.py index 82c4370543..df76ab6bb0 100644 --- a/esphome/components/lvgl/widgets/qrcode.py +++ b/esphome/components/lvgl/widgets/qrcode.py @@ -2,7 +2,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_SIZE, CONF_TEXT -from ..defines import CONF_MAIN, get_color_formats +from ..defines import CONF_MAIN from ..lv_validation import color, lv_color, lv_int, lv_text from ..lvcode import LocalVariable, lv from ..schemas import TEXT_SCHEMA @@ -44,7 +44,6 @@ class QrCodeType(WidgetType): return CONF_CANVAS, CONF_IMAGE async def to_code(self, w: Widget, config): - get_color_formats().add("ARGB8888") await w.set_property( CONF_LIGHT_COLOR, await lv_color.process(config.get(CONF_LIGHT_COLOR)) ) diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 60ba664f04..7629b03e9d 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -26,7 +26,7 @@ from ..schemas import container_schema, part_schema from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties from .button import button_spec -from .buttonmatrix import buttonmatrix_spec +from .buttonmatrix import CONF_BUTTONMATRIX, buttonmatrix_spec from .obj import obj_spec CONF_TABVIEW = "tabview" @@ -73,7 +73,7 @@ class TabviewType(WidgetType): ) def get_uses(self): - return "btnmatrix", TYPE_FLEX + return CONF_BUTTONMATRIX, TYPE_FLEX async def to_code(self, w: Widget, config: dict): await w.set_property( From 9dca7e0daf015db9a2dbe1cad391a000df335ae6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:01:33 -0400 Subject: [PATCH 118/160] [tormatic] Fix UART stream desync on ESP32 (#15337) --- .../components/tormatic/tormatic_cover.cpp | 67 ++++++++++++++----- esphome/components/tormatic/tormatic_cover.h | 1 + 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp index 77c2e87717..a58228a219 100644 --- a/esphome/components/tormatic/tormatic_cover.cpp +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -10,6 +10,10 @@ namespace tormatic { static const char *const TAG = "tormatic.cover"; +// Time to poll the UART when flushing after desync. At 9600 baud, a full +// 12-byte message takes ~12.5ms, so 15ms guarantees all bytes have arrived. +static constexpr uint32_t DRAIN_TIMEOUT_MS = 15; + using namespace esphome::cover; void Tormatic::setup() { @@ -256,32 +260,51 @@ void Tormatic::stop_at_target_() { // Read a GateStatus from the unit. The unit only sends messages in response to // status requests or commands, so a message needs to be sent first. optional Tormatic::read_gate_status_() { - if (this->available() < sizeof(MessageHeader)) { + if (!this->pending_hdr_) { + if (this->available() < sizeof(MessageHeader)) { + return {}; + } + + this->pending_hdr_ = this->read_data_(); + if (!this->pending_hdr_) { + return {}; + } + + // Sanity check: valid messages have small payloads (3-4 bytes). A large + // or impossible payload_size means the stream is out of sync (corrupted + // byte, dropped data, etc.). Flush the buffer so we can resync on the + // next request/response cycle. + if (this->pending_hdr_->payload_size() > sizeof(CommandRequestReply)) { + ESP_LOGW(TAG, "Unexpected payload size %" PRIu32 ", flushing rx buffer", this->pending_hdr_->payload_size()); + this->pending_hdr_.reset(); + this->drain_rx_(); + return {}; + } + } + + // Wait for all payload bytes to arrive before processing. + if (this->available() < this->pending_hdr_->payload_size()) { return {}; } - auto o_hdr = this->read_data_(); - if (!o_hdr) { - ESP_LOGE(TAG, "Timeout reading message header"); - return {}; - } - auto hdr = o_hdr.value(); + auto hdr = *this->pending_hdr_; + this->pending_hdr_.reset(); switch (hdr.type) { case STATUS: { if (hdr.payload_size() != sizeof(StatusReply)) { ESP_LOGE(TAG, "Header specifies payload size %" PRIu32 " but size of StatusReply is %zu", hdr.payload_size(), sizeof(StatusReply)); + this->drain_rx_(hdr.payload_size()); + return {}; } - // Read a StatusReply requested by update(). auto o_status = this->read_data_(); if (!o_status) { return {}; } - auto status = o_status.value(); - return status.state; + return o_status->state; } case COMMAND: @@ -344,16 +367,24 @@ template optional Tormatic::read_data_() { return obj; } -// Drain up to n amount of bytes from the uart rx buffer. +// Drain bytes from the uart rx buffer. When n > 0, drain exactly n bytes +// (caller must ensure they are available). When n == 0, poll for 15ms to +// guarantee a full packet time at 9600 baud has elapsed, consuming any +// bytes still in transit. void Tormatic::drain_rx_(uint16_t n) { uint8_t data; - uint16_t count = 0; - while (this->available()) { - this->read_byte(&data); - count++; - - if (n > 0 && count >= n) { - return; + if (n > 0) { + for (uint16_t i = 0; i < n; i++) { + if (!this->read_byte(&data)) { + return; + } + } + } else { + uint32_t start = millis(); + while (millis() - start < DRAIN_TIMEOUT_MS) { + if (this->available()) { + this->read_byte(&data); + } } } } diff --git a/esphome/components/tormatic/tormatic_cover.h b/esphome/components/tormatic/tormatic_cover.h index 534d4bef14..34483ed6a3 100644 --- a/esphome/components/tormatic/tormatic_cover.h +++ b/esphome/components/tormatic/tormatic_cover.h @@ -43,6 +43,7 @@ class Tormatic : public cover::Cover, public uart::UARTDevice, public PollingCom void handle_gate_status_(GateStatus s); uint32_t seq_tx_{0}; + optional pending_hdr_{}; GateStatus current_status_{PAUSED}; From 23dcc5389d12cf4bbfccc0238c959ba84aa77f13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 12:59:45 -1000 Subject: [PATCH 119/160] [time] Fix strftime %Z and %z returning wrong timezone (#15330) --- esphome/components/time/posix_tz.cpp | 13 +++++++ esphome/components/time/posix_tz.h | 3 ++ esphome/core/time.cpp | 54 ++++++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 4d1f0c74c2..f388267abd 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -4,6 +4,7 @@ #include "posix_tz.h" #include +#include namespace esphome::time { @@ -442,6 +443,18 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { return internal::parse_dst_rule(p, result.dst_end); } +// Format a POSIX offset (positive = west) as "+HHMM" / "-HHMM" for display. +// Convention: negate POSIX sign so east-of-UTC is positive (ISO 8601 / RFC 2822). +void format_designation(int32_t posix_offset, char *buf, size_t buf_size) { + int32_t display = -posix_offset; + char sign = display >= 0 ? '+' : '-'; + if (display < 0) + display = -display; + int h = display / 3600; + int m = (display % 3600) / 60; + snprintf(buf, buf_size, "%c%02d%02d", sign, h, m); +} + bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) { if (!out_tm) { return false; diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index c71ba15cd1..be1ddfd689 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -36,6 +36,9 @@ struct ParsedTimezone { bool has_dst() const { return this->dst_start.type != DSTRuleType::NONE; } }; +/// Format a POSIX offset as "+HHMM"/"-HHMM" into buf (must be >= 6 bytes). +void format_designation(int32_t posix_offset, char *buf, size_t buf_size); + /// Parse a POSIX TZ string into a ParsedTimezone struct. /// /// @deprecated Remove before 2026.9.0 (bridge code for backward compatibility). diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 650c61d37b..b6fc9b90ad 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -2,6 +2,9 @@ #include "helpers.h" #include +#ifdef USE_TIME_TIMEZONE +#include "esphome/components/time/posix_tz.h" +#endif namespace esphome { @@ -14,12 +17,59 @@ uint8_t days_in_month(uint8_t month, uint16_t year) { size_t ESPTime::strftime(char *buffer, size_t buffer_len, const char *format) { struct tm c_tm = this->to_c_tm(); +#ifdef USE_TIME_TIMEZONE + // ::strftime uses libc's internal timezone state for %Z and %z, but we + // eliminated setenv("TZ")/tzset() on embedded platforms to save flash. + // Substitute %Z and %z with correct values from our parsed timezone. + // Quick scan: does format contain %Z or %z (but not %%Z/%%z)? + bool needs_subst = false; + for (const char *p = format; *p; p++) { + if (*p == '%' && *(p + 1)) { + p++; + if (*p == '%') + continue; // %% is a literal %, skip + if (*p == 'Z' || *p == 'z') { + needs_subst = true; + break; + } + } + } + if (needs_subst) { + const auto &tz = time::get_global_tz(); + char designation[6]; // "+HHMM" + null + int32_t offset = c_tm.tm_isdst > 0 ? tz.dst_offset_seconds : tz.std_offset_seconds; + time::format_designation(offset, designation, sizeof(designation)); + + char modified[STRFTIME_BUFFER_SIZE]; + char *out = modified; + char *out_end = modified + sizeof(modified) - 1; + for (const char *p = format; *p && out < out_end; p++) { + if (*p == '%') { + if (*(p + 1) == '%') { + // %% → copy both percent signs (literal %) + *out++ = *p++; + if (out < out_end) + *out++ = *p; + } else if (*(p + 1) == 'Z' || *(p + 1) == 'z') { + p++; // skip the Z/z + for (const char *d = designation; *d && out < out_end; d++) + *out++ = *d; + } else { + *out++ = *p; + } + } else { + *out++ = *p; + } + } + *out = '\0'; + return ::strftime(buffer, buffer_len, modified, &c_tm); + } +#endif return ::strftime(buffer, buffer_len, format, &c_tm); } size_t ESPTime::strftime_to(std::span buffer, const char *format) { - struct tm c_tm = this->to_c_tm(); - size_t len = ::strftime(buffer.data(), buffer.size(), format, &c_tm); + size_t len = this->strftime(buffer.data(), buffer.size(), format); if (len > 0) { return len; } From 15bcd62f222ce4e24be1ea3f6e37b0d1c0b04cab Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:59:53 +1300 Subject: [PATCH 120/160] [internal_temperature] Move code into platform specific files (#15339) --- .../internal_temperature.h | 10 +- .../internal_temperature_bk72xx.cpp | 41 ++++++++ .../internal_temperature_common.cpp | 10 ++ ...ure.cpp => internal_temperature_esp32.cpp} | 96 ++----------------- .../internal_temperature_rp2040.cpp | 31 ++++++ .../internal_temperature_zephyr.cpp | 56 +++++++++++ .../components/internal_temperature/sensor.py | 17 ++++ 7 files changed, 170 insertions(+), 91 deletions(-) create mode 100644 esphome/components/internal_temperature/internal_temperature_bk72xx.cpp create mode 100644 esphome/components/internal_temperature/internal_temperature_common.cpp rename esphome/components/internal_temperature/{internal_temperature.cpp => internal_temperature_esp32.cpp} (54%) create mode 100644 esphome/components/internal_temperature/internal_temperature_rp2040.cpp create mode 100644 esphome/components/internal_temperature/internal_temperature_zephyr.cpp diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 78e3bcef7d..4810e8478d 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -1,18 +1,18 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" -namespace esphome { -namespace internal_temperature { +namespace esphome::internal_temperature { class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent { public: +#if defined(USE_ESP32) || (defined(USE_ZEPHYR) && defined(USE_NRF52)) void setup() override; +#endif // USE_ESP32 || (USE_ZEPHYR && USE_NRF52) void dump_config() override; void update() override; }; -} // namespace internal_temperature -} // namespace esphome +} // namespace esphome::internal_temperature diff --git a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp new file mode 100644 index 0000000000..31a92f90a5 --- /dev/null +++ b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp @@ -0,0 +1,41 @@ +#ifdef USE_BK72XX + +#include "esphome/core/log.h" +#include "internal_temperature.h" + +extern "C" { +uint32_t temp_single_get_current_temperature(uint32_t *temp_value); +} + +namespace esphome::internal_temperature { + +static const char *const TAG = "internal_temperature.bk72xx"; + +void InternalTemperatureSensor::update() { + float temperature = NAN; + bool success = false; + + uint32_t raw, result; + result = temp_single_get_current_temperature(&raw); + success = (result == 0); +#if defined(USE_LIBRETINY_VARIANT_BK7231N) + temperature = raw * -0.38f + 156.0f; +#elif defined(USE_LIBRETINY_VARIANT_BK7231T) + temperature = raw * 0.04f; +#else // USE_LIBRETINY_VARIANT + temperature = raw * 0.128f; +#endif // USE_LIBRETINY_VARIANT + + if (success && std::isfinite(temperature)) { + this->publish_state(temperature); + } else { + ESP_LOGD(TAG, "Ignoring invalid temperature (success=%d, value=%.1f)", success, temperature); + if (!this->has_state()) { + this->publish_state(NAN); + } + } +} + +} // namespace esphome::internal_temperature + +#endif // USE_BK72XX diff --git a/esphome/components/internal_temperature/internal_temperature_common.cpp b/esphome/components/internal_temperature/internal_temperature_common.cpp new file mode 100644 index 0000000000..89a7d34333 --- /dev/null +++ b/esphome/components/internal_temperature/internal_temperature_common.cpp @@ -0,0 +1,10 @@ +#include "esphome/core/log.h" +#include "internal_temperature.h" + +namespace esphome::internal_temperature { + +static const char *const TAG = "internal_temperature"; + +void InternalTemperatureSensor::dump_config() { LOG_SENSOR("", "Internal Temperature Sensor", this); } + +} // namespace esphome::internal_temperature diff --git a/esphome/components/internal_temperature/internal_temperature.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp similarity index 54% rename from esphome/components/internal_temperature/internal_temperature.cpp rename to esphome/components/internal_temperature/internal_temperature_esp32.cpp index 567ae6170e..09121fa9c9 100644 --- a/esphome/components/internal_temperature/internal_temperature.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -1,7 +1,8 @@ -#include "internal_temperature.h" -#include "esphome/core/log.h" - #ifdef USE_ESP32 + +#include "esphome/core/log.h" +#include "internal_temperature.h" + #if defined(USE_ESP32_VARIANT_ESP32) // there is no official API available on the original ESP32 extern "C" { @@ -13,70 +14,20 @@ uint8_t temprature_sens_read(); defined(USE_ESP32_VARIANT_ESP32S3) #include "driver/temperature_sensor.h" #endif // USE_ESP32_VARIANT -#endif // USE_ESP32 -#ifdef USE_RP2040 -#include "Arduino.h" -#endif // USE_RP2040 -#ifdef USE_BK72XX -extern "C" { -uint32_t temp_single_get_current_temperature(uint32_t *temp_value); -} -#endif // USE_BK72XX -#if defined(USE_ZEPHYR) && defined(USE_NRF52) -#include -#include -#endif // USE_ZEPHYR && USE_NRF52 -namespace esphome { -namespace internal_temperature { +namespace esphome::internal_temperature { + +static const char *const TAG = "internal_temperature.esp32"; -static const char *const TAG = "internal_temperature"; -#if defined(USE_ZEPHYR) && defined(USE_NRF52) -static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); -#endif // USE_ZEPHYR && USE_NRF52 -#ifdef USE_ESP32 #if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) static temperature_sensor_handle_t tsensNew = NULL; #endif // USE_ESP32_VARIANT -#endif // USE_ESP32 void InternalTemperatureSensor::update() { -#if defined(USE_ZEPHYR) && defined(USE_NRF52) - struct sensor_value value; - int result = sensor_sample_fetch(DIE_TEMPERATURE_SENSOR); - if (result != 0) { - ESP_LOGE(TAG, "Failed to fetch nRF52 die temperature sample (%d)", result); - if (!this->has_state()) { - this->publish_state(NAN); - } - return; - } - - result = sensor_channel_get(DIE_TEMPERATURE_SENSOR, SENSOR_CHAN_DIE_TEMP, &value); - if (result != 0) { - ESP_LOGE(TAG, "Failed to get nRF52 die temperature (%d)", result); - if (!this->has_state()) { - this->publish_state(NAN); - } - return; - } - - const float temperature = value.val1 + (value.val2 / 1000000.0f); - if (std::isfinite(temperature)) { - this->publish_state(temperature); - } else { - ESP_LOGD(TAG, "Ignoring invalid nRF52 temperature (value=%.1f)", temperature); - if (!this->has_state()) { - this->publish_state(NAN); - } - } -#else - float temperature = NAN; bool success = false; -#ifdef USE_ESP32 #if defined(USE_ESP32_VARIANT_ESP32) uint8_t raw = temprature_sens_read(); ESP_LOGV(TAG, "Raw temperature value: %d", raw); @@ -92,23 +43,7 @@ void InternalTemperatureSensor::update() { ESP_LOGE(TAG, "Reading failed (%d)", result); } #endif // USE_ESP32_VARIANT -#endif // USE_ESP32 -#ifdef USE_RP2040 - temperature = analogReadTemp(); - success = (temperature != 0.0f); -#endif // USE_RP2040 -#ifdef USE_BK72XX - uint32_t raw, result; - result = temp_single_get_current_temperature(&raw); - success = (result == 0); -#if defined(USE_LIBRETINY_VARIANT_BK7231N) - temperature = raw * -0.38f + 156.0f; -#elif defined(USE_LIBRETINY_VARIANT_BK7231T) - temperature = raw * 0.04f; -#else // USE_LIBRETINY_VARIANT - temperature = raw * 0.128f; -#endif // USE_LIBRETINY_VARIANT -#endif // USE_BK72XX + if (success && std::isfinite(temperature)) { this->publish_state(temperature); } else { @@ -117,18 +52,9 @@ void InternalTemperatureSensor::update() { this->publish_state(NAN); } } -#endif // USE_ZEPHYR && USE_NRF52 } void InternalTemperatureSensor::setup() { -#if defined(USE_ZEPHYR) && defined(USE_NRF52) - if (!device_is_ready(DIE_TEMPERATURE_SENSOR)) { - ESP_LOGE(TAG, "nRF52 die temperature sensor device %s not ready", DIE_TEMPERATURE_SENSOR->name); - this->mark_failed(); - return; - } -#endif // USE_ZEPHYR && USE_NRF52 -#ifdef USE_ESP32 #if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) @@ -148,10 +74,8 @@ void InternalTemperatureSensor::setup() { return; } #endif // USE_ESP32_VARIANT -#endif // USE_ESP32 } -void InternalTemperatureSensor::dump_config() { LOG_SENSOR("", "Internal Temperature Sensor", this); } +} // namespace esphome::internal_temperature -} // namespace internal_temperature -} // namespace esphome +#endif // USE_ESP32 diff --git a/esphome/components/internal_temperature/internal_temperature_rp2040.cpp b/esphome/components/internal_temperature/internal_temperature_rp2040.cpp new file mode 100644 index 0000000000..66dee9faf7 --- /dev/null +++ b/esphome/components/internal_temperature/internal_temperature_rp2040.cpp @@ -0,0 +1,31 @@ +#ifdef USE_RP2040 + +#include "esphome/core/log.h" +#include "internal_temperature.h" + +#include "Arduino.h" + +namespace esphome::internal_temperature { + +static const char *const TAG = "internal_temperature.rp2040"; + +void InternalTemperatureSensor::update() { + float temperature = NAN; + bool success = false; + + temperature = analogReadTemp(); + success = (temperature != 0.0f); + + if (success && std::isfinite(temperature)) { + this->publish_state(temperature); + } else { + ESP_LOGD(TAG, "Ignoring invalid temperature (success=%d, value=%.1f)", success, temperature); + if (!this->has_state()) { + this->publish_state(NAN); + } + } +} + +} // namespace esphome::internal_temperature + +#endif // USE_RP2040 diff --git a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp new file mode 100644 index 0000000000..be72ab6f51 --- /dev/null +++ b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp @@ -0,0 +1,56 @@ +#if defined(USE_ZEPHYR) && defined(USE_NRF52) + +#include "esphome/core/log.h" +#include "internal_temperature.h" + +#include +#include + +namespace esphome::internal_temperature { + +static const char *const TAG = "internal_temperature.zephyr"; + +static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); + +void InternalTemperatureSensor::update() { + struct sensor_value value; + int result = sensor_sample_fetch(DIE_TEMPERATURE_SENSOR); + if (result != 0) { + ESP_LOGE(TAG, "Failed to fetch nRF52 die temperature sample (%d)", result); + if (!this->has_state()) { + this->publish_state(NAN); + } + return; + } + + result = sensor_channel_get(DIE_TEMPERATURE_SENSOR, SENSOR_CHAN_DIE_TEMP, &value); + if (result != 0) { + ESP_LOGE(TAG, "Failed to get nRF52 die temperature (%d)", result); + if (!this->has_state()) { + this->publish_state(NAN); + } + return; + } + + const float temperature = value.val1 + (value.val2 / 1000000.0f); + if (std::isfinite(temperature)) { + this->publish_state(temperature); + } else { + ESP_LOGD(TAG, "Ignoring invalid nRF52 temperature (value=%.1f)", temperature); + if (!this->has_state()) { + this->publish_state(NAN); + } + } +} + +void InternalTemperatureSensor::setup() { + if (!device_is_ready(DIE_TEMPERATURE_SENSOR)) { + ESP_LOGE(TAG, "nRF52 die temperature sensor device %s not ready", DIE_TEMPERATURE_SENSOR->name); + this->mark_failed(); + return; + } +} + +} // namespace esphome::internal_temperature + +#endif // USE_ZEPHYR && USE_NRF52 diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index 965e7f0520..6d79e08675 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.zephyr import zephyr_add_prj_conf +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_TEMPERATURE, @@ -11,6 +12,7 @@ from esphome.const import ( PLATFORM_RP2040, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, + PlatformFramework, ) from esphome.core import CORE @@ -39,3 +41,18 @@ async def to_code(config): if CORE.using_zephyr and CORE.is_nrf52: zephyr_add_prj_conf("SENSOR", True) zephyr_add_prj_conf("TEMP_NRF5", True) + + +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "internal_temperature_esp32.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "internal_temperature_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "internal_temperature_bk72xx.cpp": { + PlatformFramework.BK72XX_ARDUINO, + }, + "internal_temperature_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + } +) From b71c406e704f1d751484404737a91c2b8035ddbb Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:04:07 +0200 Subject: [PATCH 121/160] [uart] fix baud rate not applied on `load_settings()` for ESP32 (IDF) (#15341) --- .../uart/uart_component_esp_idf.cpp | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 6d9d44e97f..93e43e0372 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -147,6 +147,20 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + // uart_param_config must be called after uart_driver_install and before any + // other uart_set_*() calls. The driver installation resets the UART peripheral + // registers to their default state, overwriting any previously configured baud + // rate or framing settings. Calling uart_param_config here ensures the requested + // settings are applied after the reset and before pin routing, inversion, and + // threshold configuration. + uart_config_t uart_config = this->get_config_(); + err = uart_param_config(this->uart_num_, &uart_config); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; @@ -214,22 +228,15 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; - err = uart_set_mode(this->uart_num_, mode); // per docs, must be called only after uart_driver_install() + err = uart_set_mode(this->uart_num_, mode); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); this->mark_failed(); return; } - uart_config_t uart_config = this->get_config_(); - err = uart_param_config(this->uart_num_, &uart_config); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - #ifdef USE_UART_WAKE_LOOP_ON_RX // Register ISR callback to wake the main loop when UART data arrives. // The callback runs in ISR context and uses vTaskNotifyGiveFromISR() to From 4a23ba7d8a2b28f5245674fc8337227e6f50ed08 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 31 Mar 2026 19:06:48 -0400 Subject: [PATCH 122/160] [mixer] Fix memory leak in mixer task on stop/start cycles (#15185) --- .../mixer/speaker/mixer_speaker.cpp | 274 +++++++++--------- 1 file changed, 137 insertions(+), 137 deletions(-) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 9d11abb327..0fabc68c70 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -597,173 +597,173 @@ void MixerSpeaker::audio_mixer_task(void *params) { xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STARTING); - std::unique_ptr output_transfer_buffer = audio::AudioSinkTransferBuffer::create( - this_mixer->audio_stream_info_.value().ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); + { // Ensure C++ objects fall out of scope to ensure proper cleanup before stopping the task + std::unique_ptr output_transfer_buffer = audio::AudioSinkTransferBuffer::create( + this_mixer->audio_stream_info_.value().ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); - if (output_transfer_buffer == nullptr) { - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED | MIXER_TASK_ERR_ESP_NO_MEM); + if (output_transfer_buffer == nullptr) { + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED | MIXER_TASK_ERR_ESP_NO_MEM); - vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it - } - - output_transfer_buffer->set_sink(this_mixer->output_speaker_); - - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_RUNNING); - - bool sent_finished = false; - - // Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema) - FixedVector speakers_with_data; - FixedVector> transfer_buffers_with_data; - speakers_with_data.init(this_mixer->source_speakers_.size()); - transfer_buffers_with_data.init(this_mixer->source_speakers_.size()); - - while (true) { - uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_); - if (event_group_bits & MIXER_TASK_COMMAND_STOP) { - break; + vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } - // Never shift the data in the output transfer buffer to avoid unnecessary, slow data moves - output_transfer_buffer->transfer_data_to_sink(pdMS_TO_TICKS(TASK_DELAY_MS), false); + output_transfer_buffer->set_sink(this_mixer->output_speaker_); - const uint32_t output_frames_free = - this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free()); + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_RUNNING); - speakers_with_data.clear(); - transfer_buffers_with_data.clear(); + bool sent_finished = false; - for (auto &speaker : this_mixer->source_speakers_) { - if (speaker->is_running() && !speaker->get_pause_state()) { - // Speaker is running and not paused, so it possibly can provide audio data - std::shared_ptr transfer_buffer = speaker->get_transfer_buffer().lock(); - if (transfer_buffer.use_count() == 0) { - // No transfer buffer allocated, so skip processing this speaker - continue; - } - speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers + // Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema) + FixedVector speakers_with_data; + FixedVector> transfer_buffers_with_data; + speakers_with_data.init(this_mixer->source_speakers_.size()); + transfer_buffers_with_data.init(this_mixer->source_speakers_.size()); - if (transfer_buffer->available() > 0) { - // Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop - transfer_buffers_with_data.push_back(transfer_buffer); - speakers_with_data.push_back(speaker); + while (true) { + uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_); + if (event_group_bits & MIXER_TASK_COMMAND_STOP) { + break; + } + + // Never shift the data in the output transfer buffer to avoid unnecessary, slow data moves + output_transfer_buffer->transfer_data_to_sink(pdMS_TO_TICKS(TASK_DELAY_MS), false); + + const uint32_t output_frames_free = + this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free()); + + speakers_with_data.clear(); + transfer_buffers_with_data.clear(); + + for (auto &speaker : this_mixer->source_speakers_) { + if (speaker->is_running() && !speaker->get_pause_state()) { + // Speaker is running and not paused, so it possibly can provide audio data + std::shared_ptr transfer_buffer = speaker->get_transfer_buffer().lock(); + if (transfer_buffer.use_count() == 0) { + // No transfer buffer allocated, so skip processing this speaker + continue; + } + speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers + + if (transfer_buffer->available() > 0) { + // Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop + transfer_buffers_with_data.push_back(transfer_buffer); + speakers_with_data.push_back(speaker); + } } } - } - if (transfer_buffers_with_data.empty()) { - // No audio available for transferring, block task temporarily - delay(TASK_DELAY_MS); - continue; - } + if (transfer_buffers_with_data.empty()) { + // No audio available for transferring, block task temporarily + delay(TASK_DELAY_MS); + continue; + } - uint32_t frames_to_mix = output_frames_free; + uint32_t frames_to_mix = output_frames_free; - if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) { - // Only one speaker has audio data, just copy samples over + if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) { + // Only one speaker has audio data, just copy samples over - audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info(); + audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info(); - if (active_stream_info.get_sample_rate() == - this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) { - // Speaker's sample rate matches the output speaker's, copy directly + if (active_stream_info.get_sample_rate() == + this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) { + // Speaker's sample rate matches the output speaker's, copy directly - const uint32_t frames_available_in_buffer = - active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available()); - frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - copy_frames(reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()), active_stream_info, - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); + const uint32_t frames_available_in_buffer = + active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available()); + frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); + copy_frames(reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()), + active_stream_info, reinterpret_cast(output_transfer_buffer->get_buffer_end()), + this_mixer->audio_stream_info_.value(), frames_to_mix); - // Set playback delay for newly contributing source - if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { - speakers_with_data[0]->playback_delay_frames_.store( - this_mixer->frames_in_pipeline_.load(std::memory_order_acquire), std::memory_order_release); - speakers_with_data[0]->has_contributed_.store(true, std::memory_order_release); + // Set playback delay for newly contributing source + if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { + speakers_with_data[0]->playback_delay_frames_.store( + this_mixer->frames_in_pipeline_.load(std::memory_order_acquire), std::memory_order_release); + speakers_with_data[0]->has_contributed_.store(true, std::memory_order_release); + } + + // Update source speaker pending frames + speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); + transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix)); + + // Update output transfer buffer length and pipeline frame count + output_transfer_buffer->increase_buffer_length( + this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); + } else { + // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker + if (!this_mixer->output_speaker_->is_stopped()) { + if (!sent_finished) { + this_mixer->output_speaker_->finish(); + sent_finished = true; // Avoid repeatedly sending the finish command + } + } else { + // Speaker has finished writing the current audio, update the stream information and restart the speaker + this_mixer->audio_stream_info_ = + audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_, + active_stream_info.get_sample_rate()); + this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); + this_mixer->output_speaker_->start(); + // Reset pipeline frame count since we're starting fresh with a new sample rate + this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); + sent_finished = false; + } + } + } else { + // Determine how many frames to mix + for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { + const uint32_t frames_available_in_buffer = speakers_with_data[i]->get_audio_stream_info().bytes_to_frames( + transfer_buffers_with_data[i]->available()); + frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); + } + int16_t *primary_buffer = reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()); + audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); + + // Mix two streams together + for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) { + mix_audio_samples(primary_buffer, primary_stream_info, + reinterpret_cast(transfer_buffers_with_data[i]->get_buffer_start()), + speakers_with_data[i]->get_audio_stream_info(), + reinterpret_cast(output_transfer_buffer->get_buffer_end()), + this_mixer->audio_stream_info_.value(), frames_to_mix); + + if (i != transfer_buffers_with_data.size() - 1) { + // Need to mix more streams together, point primary buffer and stream info to the already mixed output + primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); + primary_stream_info = this_mixer->audio_stream_info_.value(); + } } - // Update source speaker pending frames - speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); - transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix)); + // Get current pipeline depth for delay calculation (before incrementing) + uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire); - // Update output transfer buffer length and pipeline frame count + // Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks + for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { + // Set playback delay for newly contributing sources + if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) { + speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release); + speakers_with_data[i]->has_contributed_.store(true, std::memory_order_release); + } + + speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); + transfer_buffers_with_data[i]->decrease_buffer_length( + speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix)); + } + + // Update output transfer buffer length and pipeline frame count (once, not per source) output_transfer_buffer->increase_buffer_length( this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); - } else { - // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker - if (!this_mixer->output_speaker_->is_stopped()) { - if (!sent_finished) { - this_mixer->output_speaker_->finish(); - sent_finished = true; // Avoid repeatedly sending the finish command - } - } else { - // Speaker has finished writing the current audio, update the stream information and restart the speaker - this_mixer->audio_stream_info_ = - audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_, - active_stream_info.get_sample_rate()); - this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); - this_mixer->output_speaker_->start(); - // Reset pipeline frame count since we're starting fresh with a new sample rate - this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); - sent_finished = false; - } } - } else { - // Determine how many frames to mix - for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { - const uint32_t frames_available_in_buffer = - speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(transfer_buffers_with_data[i]->available()); - frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - } - int16_t *primary_buffer = reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()); - audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); - - // Mix two streams together - for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) { - mix_audio_samples(primary_buffer, primary_stream_info, - reinterpret_cast(transfer_buffers_with_data[i]->get_buffer_start()), - speakers_with_data[i]->get_audio_stream_info(), - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); - - if (i != transfer_buffers_with_data.size() - 1) { - // Need to mix more streams together, point primary buffer and stream info to the already mixed output - primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); - primary_stream_info = this_mixer->audio_stream_info_.value(); - } - } - - // Get current pipeline depth for delay calculation (before incrementing) - uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire); - - // Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks - for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { - // Set playback delay for newly contributing sources - if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) { - speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release); - speakers_with_data[i]->has_contributed_.store(true, std::memory_order_release); - } - - speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); - transfer_buffers_with_data[i]->decrease_buffer_length( - speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix)); - } - - // Update output transfer buffer length and pipeline frame count (once, not per source) - output_transfer_buffer->increase_buffer_length( - this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); - this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } - } - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPING); + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPING); + } // Reset pipeline frame count since the task is stopping this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); - output_transfer_buffer.reset(); - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED); vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it From 954227b2031962cf074615981b471613206be47b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 13:26:26 -1000 Subject: [PATCH 123/160] [esp32_ble_tracker] Restart BLE scan after OTA failure (#15308) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 6 ++++++ esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 3 +++ 2 files changed, 9 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 6dce70f839..f2d60be641 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -88,12 +88,18 @@ void ESP32BLETracker::setup() { #ifdef USE_OTA_STATE_LISTENER void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { if (state == ota::OTA_STARTED) { + this->scan_continuous_before_ota_ = this->scan_continuous_; this->stop_scan(); #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT for (auto *client : this->clients_) { client->disconnect(); } #endif + } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { + this->scan_continuous_before_ota_ = false; + this->scan_continuous_ = true; + // Do not restart scanning immediately here; allow loop() to + // safely restart scanning once the scanner and all clients are idle. } } #endif diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index ff69a4dcd2..43405b02b7 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -431,6 +431,9 @@ class ESP32BLETracker : public Component, ScannerState scanner_state_{ScannerState::IDLE}; bool scan_continuous_; bool scan_active_; +#ifdef USE_OTA_STATE_LISTENER + bool scan_continuous_before_ota_{false}; +#endif bool ble_was_disabled_{true}; bool raw_advertisements_{false}; bool parse_advertisements_{false}; From e261b5de655dbcda6a1fbda83aec1fbdfd8c5c1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Mar 2026 08:22:25 -1000 Subject: [PATCH 124/160] [time] Point to valid IANA timezone list on validation failure (#15110) --- esphome/components/time/__init__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 9821046a73..c31ccbc7ea 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -284,13 +284,23 @@ def validate_tz(value: str) -> str: tzfile = _load_tzdata(value) if tzfile is not None: value = _extract_tz_string(tzfile) + is_iana = True + else: + is_iana = False # Validate that the POSIX TZ string is parseable (skip empty strings) if value: try: parse_posix_tz_python(value) except ValueError as e: - raise cv.Invalid(f"Invalid POSIX timezone string '{value}': {e}") from e + if is_iana: + raise cv.Invalid(f"Invalid POSIX timezone string '{value}': {e}") from e + raise cv.Invalid( + f"Invalid POSIX timezone string '{value}': {e}. " + f"If you meant to use an IANA timezone, check the list of valid " + f"timezones at " + f"https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" + ) from e return value From f7b410fd0c14e0bf00bddab3f03227087957d7f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Mar 2026 08:47:15 -1000 Subject: [PATCH 125/160] [wifi] Fix roaming attempt counter reset on disconnect during scan (#15099) --- esphome/components/wifi/wifi_component.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 09f883ed61..aa4e691cd0 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -269,11 +269,11 @@ bool CompactString::operator==(const StringRef &other) const { /// │ │ │ /// │ ┌──────────────┼──────────────┐ │ /// │ ↓ ↓ ↓ │ -/// │ scan error no better AP +10 dB better AP │ +/// │ disconnect no better AP +10 dB better AP │ /// │ │ │ │ │ /// │ ↓ ↓ ↓ │ /// │ ┌──────────────────────────────┐ ┌──────────────────────────┐ │ -/// │ │ → IDLE │ │ CONNECTING │ │ +/// │ │ → RECONNECTING │ │ CONNECTING │ │ /// │ │ (counter preserved) │ │ (process_roaming_scan_) │ │ /// │ └──────────────────────────────┘ └────────────┬─────────────┘ │ /// │ │ │ @@ -296,7 +296,7 @@ bool CompactString::operator==(const StringRef &other) const { /// │ Key behaviors: │ /// │ - After 3 checks: attempts >= 3, stop checking │ /// │ - Non-roaming disconnect: clear_roaming_state_() resets counter │ -/// │ - Scan error (SCANNING→IDLE): counter preserved │ +/// │ - Disconnect during scan (SCANNING→RECONNECTING): counter preserved │ /// │ - Roaming success (CONNECTING→IDLE): counter reset (can roam again) │ /// │ - Roaming fail (RECONNECTING→IDLE): counter preserved (ping-pong) │ /// └──────────────────────────────────────────────────────────────────────┘ @@ -2075,9 +2075,10 @@ void WiFiComponent::retry_connect() { ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); this->roaming_state_ = RoamingState::RECONNECTING; } else if (this->roaming_state_ == RoamingState::SCANNING) { - // Roam scan failed (e.g., scan error on ESP8266) - go back to idle, keep counter - ESP_LOGD(TAG, "Roam scan failed (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); - this->roaming_state_ = RoamingState::IDLE; + // Disconnected during roam scan - transition to RECONNECTING so the attempts + // counter is preserved when reconnection succeeds (IDLE would reset it) + ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); + this->roaming_state_ = RoamingState::RECONNECTING; } else if (this->roaming_state_ == RoamingState::IDLE) { // Not a roaming-triggered reconnect, reset state this->clear_roaming_state_(); From d9788aaefc337faaa20e4cde5681368ae3e18a4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Mar 2026 13:58:36 -1000 Subject: [PATCH 126/160] [wifi] Reduce ESP8266 roaming scan dwell time to match ESP32 (#15127) --- .../components/wifi/wifi_component_esp8266.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 5514f1c6be..517b59da37 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -664,11 +664,22 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { config.show_hidden = 1; #if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0) config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE; + // Use shorter dwell times for roaming scans - we only need to detect strong + // nearby APs, not do a thorough survey. This also reduces off-channel time + // which can cause Beacon Timeout disconnects on some APs. + // Roaming times match the ESP32 IDF scan defaults. + static constexpr uint32_t SCAN_PASSIVE_DEFAULT_MS = 500; + static constexpr uint32_t SCAN_PASSIVE_ROAMING_MS = 300; + static constexpr uint32_t SCAN_ACTIVE_MIN_DEFAULT_MS = 400; + static constexpr uint32_t SCAN_ACTIVE_MAX_DEFAULT_MS = 500; + static constexpr uint32_t SCAN_ACTIVE_MIN_ROAMING_MS = 100; + static constexpr uint32_t SCAN_ACTIVE_MAX_ROAMING_MS = 300; + bool roaming = this->roaming_state_ == RoamingState::SCANNING; if (passive) { - config.scan_time.passive = 500; + config.scan_time.passive = roaming ? SCAN_PASSIVE_ROAMING_MS : SCAN_PASSIVE_DEFAULT_MS; } else { - config.scan_time.active.min = 400; - config.scan_time.active.max = 500; + config.scan_time.active.min = roaming ? SCAN_ACTIVE_MIN_ROAMING_MS : SCAN_ACTIVE_MIN_DEFAULT_MS; + config.scan_time.active.max = roaming ? SCAN_ACTIVE_MAX_ROAMING_MS : SCAN_ACTIVE_MAX_DEFAULT_MS; } #endif bool ret = wifi_station_scan(&config, &WiFiComponent::s_wifi_scan_done_callback); From 2f2c7ac393b23f52e660b928ace0fcd0becf9176 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:04:27 -0400 Subject: [PATCH 127/160] [sx127x] Fix FIFO read corruption (#15114) --- esphome/components/sx127x/sx127x.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 66957a7342..0fddfdccdb 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -38,14 +38,18 @@ void SX127x::write_register_(uint8_t reg, uint8_t value) { void SX127x::read_fifo_(std::vector &packet) { this->enable(); this->write_byte(REG_FIFO & 0x7F); - this->read_array(packet.data(), packet.size()); + for (auto &byte : packet) { + byte = this->transfer_byte(0x00); + } this->disable(); } void SX127x::write_fifo_(const std::vector &packet) { this->enable(); this->write_byte(REG_FIFO | 0x80); - this->write_array(packet.data(), packet.size()); + for (const auto &byte : packet) { + this->transfer_byte(byte); + } this->disable(); } From cb15e98765e69377bf7369cf95fb21386dd21d57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Mar 2026 14:03:56 -1000 Subject: [PATCH 128/160] [datetime] Fix state_as_esptime() returning invalid timestamp (#15128) --- .../components/datetime/datetime_entity.cpp | 3 + esphome/core/time.cpp | 2 +- esphome/core/time.h | 18 ++++-- tests/components/time/posix_tz_parser.cpp | 56 ++++++++++++++++++- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index 730abb3ca8..fa50271f04 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -60,6 +60,9 @@ ESPTime DateTimeEntity::state_as_esptime() const { obj.year = this->year_; obj.month = this->month_; obj.day_of_month = this->day_; + obj.day_of_week = 0; + obj.day_of_year = 0; + obj.is_dst = false; obj.hour = this->hour_; obj.minute = this->minute_; obj.second = this->second_; diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 6add82e7d1..650c61d37b 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -231,7 +231,7 @@ void ESPTime::increment_day() { void ESPTime::recalc_timestamp_utc(bool use_day_of_year) { time_t res = 0; - if (!this->fields_in_range()) { + if (!this->fields_in_range(false, use_day_of_year)) { this->timestamp = -1; return; } diff --git a/esphome/core/time.h b/esphome/core/time.h index 1716c51ffd..ed47432038 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -79,11 +79,19 @@ struct ESPTime { /// Check if this ESPTime is valid (all fields in range and year is greater than or equal to 2019) bool is_valid() const { return this->year >= 2019 && this->fields_in_range(); } - /// Check if all time fields of this ESPTime are in range. - bool fields_in_range() const { - return this->second < 61 && this->minute < 60 && this->hour < 24 && this->day_of_week > 0 && - this->day_of_week < 8 && this->day_of_year > 0 && this->day_of_year < 367 && this->month > 0 && - this->month < 13 && this->day_of_month > 0 && this->day_of_month <= days_in_month(this->month, this->year); + /// Check if time fields are in range. + /// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields) + /// @param check_day_of_year validate day_of_year (not always available when constructing from date/time fields) + bool fields_in_range(bool check_day_of_week = true, bool check_day_of_year = true) const { + bool valid = this->second < 61 && this->minute < 60 && this->hour < 24 && this->month > 0 && this->month < 13 && + this->day_of_month > 0 && this->day_of_month <= days_in_month(this->month, this->year); + if (check_day_of_week) { + valid = valid && this->day_of_week > 0 && this->day_of_week < 8; + } + if (check_day_of_year) { + valid = valid && this->day_of_year > 0 && this->day_of_year < 367; + } + return valid; } /** Convert a string to ESPTime struct as specified by the format argument. diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index d1747ef5b1..b7cf2a4afa 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -1036,8 +1036,6 @@ static time_t esptime_recalc_local(int year, int month, int day, int hour, int m t.hour = hour; t.minute = min; t.second = sec; - t.day_of_week = 1; // Placeholder for fields_in_range() - t.day_of_year = 1; t.recalc_timestamp_local(); return t.timestamp; } @@ -1187,6 +1185,60 @@ TEST(RecalcTimestampLocal, NonDefaultTransitionTime) { EXPECT_EQ(esp_result, libc_result); } +TEST(RecalcTimestampLocal, MinimalFieldsWithoutDayOfWeekOrYear) { + // Regression test for issue #15115: DateTimeEntity::state_as_esptime() constructs + // an ESPTime with only year/month/day/hour/minute/second set (no day_of_week or + // day_of_year). recalc_timestamp_local() must work without those fields. + const char *tz_str = "CET-1CEST,M3.5.0,M10.5.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Construct ESPTime with only date/time fields (like state_as_esptime does) + ESPTime t{}; + t.year = 2026; + t.month = 3; + t.day_of_month = 20; + t.hour = 23; + t.minute = 14; + t.second = 55; + // day_of_week and day_of_year are deliberately left as 0 + t.recalc_timestamp_local(); + + // Must NOT return -1 (the bug: fields_in_range() rejected valid times) + EXPECT_NE(t.timestamp, -1); + + // Verify against libc + time_t libc_result = libc_mktime(2026, 3, 20, 23, 14, 55); + EXPECT_EQ(t.timestamp, libc_result); +} + +TEST(RecalcTimestampLocal, MinimalFieldsNoDST) { + // Same test but with a timezone that has no DST + const char *tz_str = "IST-5:30"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + ESPTime t{}; + t.year = 2026; + t.month = 3; + t.day_of_month = 23; + t.hour = 10; + t.minute = 0; + t.second = 0; + t.recalc_timestamp_local(); + + EXPECT_NE(t.timestamp, -1); + + time_t libc_result = libc_mktime(2026, 3, 23, 10, 0, 0); + EXPECT_EQ(t.timestamp, libc_result); +} + TEST(RecalcTimestampLocal, YearBoundaryDST) { // Test southern hemisphere DST across year boundary // Australia/Sydney: DST active from October to April (spans Jan 1) From f5f99071fb51f9cd61ed3bcf2e2f63e3a1555c77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Mar 2026 14:04:17 -1000 Subject: [PATCH 129/160] [wifi] Fix roaming counter reset from delayed disconnect and successful retry (#15126) --- esphome/components/wifi/wifi_component.cpp | 66 +++++++++++++++++----- esphome/components/wifi/wifi_component.h | 6 ++ 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index aa4e691cd0..8656df7f4d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -287,18 +287,25 @@ bool CompactString::operator==(const StringRef &other) const { /// │ │ (counter reset to 0) │ │ (retry_connect called) │ /// │ └──────────────────────────────────┘ └───────────┬─────────────┘ /// │ │ │ -/// │ ↓ │ -/// │ ┌───────────────────────┐ │ -/// │ │ → IDLE │ │ -/// │ │ (counter preserved!) │ │ -/// │ └───────────────────────┘ │ +/// │ ┌─────────┴─────────┐ │ +/// │ ↓ ↓ │ +/// │ on target BSSID on other AP │ +/// │ │ │ │ +/// │ ↓ ↓ │ +/// │ ┌──────────────────┐ ┌────────────┐│ +/// │ │ → IDLE │ │ → IDLE ││ +/// │ │ (counter reset) │ │ (counter ││ +/// │ │ (roam worked!) │ │ preserved)││ +/// │ └──────────────────┘ └────────────┘│ /// │ │ /// │ Key behaviors: │ /// │ - After 3 checks: attempts >= 3, stop checking │ /// │ - Non-roaming disconnect: clear_roaming_state_() resets counter │ -/// │ - Disconnect during scan (SCANNING→RECONNECTING): counter preserved │ +/// │ - Disconnect during scan (SCANNING→RECONNECTING): counter preserved │ +/// │ - Disconnect after scan (within grace period): counter preserved │ /// │ - Roaming success (CONNECTING→IDLE): counter reset (can roam again) │ -/// │ - Roaming fail (RECONNECTING→IDLE): counter preserved (ping-pong) │ +/// │ - Roaming success via retry (on target BSSID): counter reset │ +/// │ - Roaming fail (RECONNECTING on other AP): counter preserved │ /// └──────────────────────────────────────────────────────────────────────┘ // Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266) @@ -1583,17 +1590,33 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { // Only preserve attempts if reconnecting after a failed roam attempt // This prevents ping-pong between APs when a roam target is unreachable if (this->roaming_state_ == RoamingState::CONNECTING) { - // Successful roam to better AP - reset attempts so we can roam again later + // Successful roam to better AP on first try - reset attempts so we can roam again later ESP_LOGD(TAG, "Roam successful"); this->roaming_attempts_ = 0; } else if (this->roaming_state_ == RoamingState::RECONNECTING) { - // Failed roam, reconnected via normal recovery - keep attempts to prevent ping-pong - ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); + // Check if we ended up on the roam target despite needing a retry + // (e.g., first connect failed but scan-based retry found and connected to the same better AP) + bssid_t current_bssid = this->wifi_bssid(); + if (this->roaming_target_bssid_ != bssid_t{} && current_bssid == this->roaming_target_bssid_) { + char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(current_bssid.data(), bssid_buf); + ESP_LOGD(TAG, "Roam successful (via retry, attempt %u/%u) to %s", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS, + bssid_buf); + this->roaming_attempts_ = 0; + } else if (this->roaming_target_bssid_ != bssid_t{}) { + // Failed roam to specific target, reconnected to different AP - keep attempts to prevent ping-pong + ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); + } else { + // Reconnected after scan-induced disconnect (no roam target) - keep attempts + ESP_LOGD(TAG, "Reconnected after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); + } } else { // Normal connection (boot, credentials changed, etc.) this->roaming_attempts_ = 0; } this->roaming_state_ = RoamingState::IDLE; + this->roaming_target_bssid_ = {}; + this->roaming_scan_end_ = 0; // Clear all priority penalties - the next reconnect will happen when an AP disconnects, // which means the landscape has likely changed and previous tracked failures are stale @@ -2080,8 +2103,16 @@ void WiFiComponent::retry_connect() { ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); this->roaming_state_ = RoamingState::RECONNECTING; } else if (this->roaming_state_ == RoamingState::IDLE) { - // Not a roaming-triggered reconnect, reset state - this->clear_roaming_state_(); + // Check if a roaming scan recently completed - on ESP8266, going off-channel + // during scan can cause a delayed Beacon Timeout 8-20 seconds after scan finishes. + // Transition to RECONNECTING so the attempts counter is preserved on reconnect. + if (this->roaming_scan_end_ != 0 && millis() - this->roaming_scan_end_ < ROAMING_SCAN_GRACE_PERIOD) { + ESP_LOGD(TAG, "Disconnect after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); + this->roaming_state_ = RoamingState::RECONNECTING; + } else { + // Not a roaming-triggered reconnect, reset state + this->clear_roaming_state_(); + } } // RECONNECTING: keep state and counter, still trying to reconnect @@ -2316,6 +2347,8 @@ bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this-> void WiFiComponent::clear_roaming_state_() { this->roaming_attempts_ = 0; this->roaming_last_check_ = 0; + this->roaming_scan_end_ = 0; + this->roaming_target_bssid_ = {}; this->roaming_state_ = RoamingState::IDLE; } @@ -2383,7 +2416,7 @@ void WiFiComponent::check_roaming_(uint32_t now) { // Guard: skip scan if signal is already good (no meaningful improvement possible) int8_t rssi = this->wifi_rssi(); if (rssi > ROAMING_GOOD_RSSI) { - ESP_LOGV(TAG, "Roam check skipped, signal good (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, + ESP_LOGD(TAG, "Roam check skipped, signal good (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); return; } @@ -2397,6 +2430,9 @@ void WiFiComponent::process_roaming_scan_() { this->scan_done_ = false; // Default to IDLE - will be set to CONNECTING if we find a better AP this->roaming_state_ = RoamingState::IDLE; + // Record when scan completed so delayed disconnects (e.g., ESP8266 Beacon Timeout) + // can be attributed to the scan and avoid resetting the attempts counter + this->roaming_scan_end_ = millis(); // Get current connection info int8_t current_rssi = this->wifi_rssi(); @@ -2445,10 +2481,12 @@ void WiFiComponent::process_roaming_scan_() { WiFiAP roam_params = *selected; apply_scan_result_to_params(roam_params, *best); - this->release_scan_results_(); // Mark as roaming attempt - affects retry behavior if connection fails this->roaming_state_ = RoamingState::CONNECTING; + this->roaming_target_bssid_ = best->get_bssid(); // Must read before releasing scan results + + this->release_scan_results_(); // Connect directly - wifi_sta_connect_ handles disconnect internally this->start_connecting(roam_params); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 883cc1344b..27a46a8e03 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -779,6 +779,10 @@ class WiFiComponent : public Component { static constexpr int8_t ROAMING_MIN_IMPROVEMENT = 10; // dB static constexpr int8_t ROAMING_GOOD_RSSI = -49; // Skip scan if signal is excellent static constexpr uint8_t ROAMING_MAX_ATTEMPTS = 3; + // Grace period after roaming scan completes. If WiFi disconnects within this + // window (e.g., ESP8266 Beacon Timeout caused by going off-channel during scan), + // the disconnect is treated as roaming-related and the attempts counter is preserved. + static constexpr uint32_t ROAMING_SCAN_GRACE_PERIOD = 30 * 1000; // 30 seconds // 4-byte members float output_power_{NAN}; @@ -786,6 +790,7 @@ class WiFiComponent : public Component { uint32_t last_connected_{0}; uint32_t reboot_timeout_{}; uint32_t roaming_last_check_{0}; + uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed #ifdef USE_WIFI_AP uint32_t ap_timeout_{}; #endif @@ -810,6 +815,7 @@ class WiFiComponent : public Component { bool error_from_callback_{false}; RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY}; RoamingState roaming_state_{RoamingState::IDLE}; + bssid_t roaming_target_bssid_{}; // BSSID of the AP we're trying to roam to #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE}; #endif From 92642df419ad4ca8a7dae98ddb85cef050e8e5c2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:21:04 -0400 Subject: [PATCH 130/160] [wifi] Filter fast_connect by band_mode and use background scan for roaming (#15152) --- esphome/components/wifi/wifi_component.cpp | 8 ++++++++ esphome/components/wifi/wifi_component.h | 2 ++ esphome/components/wifi/wifi_component_esp_idf.cpp | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 8656df7f4d..6163571bc9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2229,6 +2229,14 @@ bool WiFiComponent::load_fast_connect_settings_(WiFiAP ¶ms) { params.set_hidden(false); ESP_LOGD(TAG, "Loaded fast_connect settings"); +#if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) + if ((this->band_mode_ == WIFI_BAND_MODE_5G_ONLY && fast_connect_save.channel < FIRST_5GHZ_CHANNEL) || + (this->band_mode_ == WIFI_BAND_MODE_2G_ONLY && fast_connect_save.channel >= FIRST_5GHZ_CHANNEL)) { + ESP_LOGW(TAG, "Saved channel %u not allowed by band mode, ignoring fast_connect", fast_connect_save.channel); + this->selected_sta_index_ = -1; + return false; + } +#endif return true; } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 27a46a8e03..c88fffc512 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -774,6 +774,8 @@ class WiFiComponent : public Component { SemaphoreHandle_t high_performance_semaphore_{nullptr}; #endif + static constexpr uint8_t FIRST_5GHZ_CHANNEL = 36; + // Post-connect roaming constants static constexpr uint32_t ROAMING_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes static constexpr int8_t ROAMING_MIN_IMPROVEMENT = 10; // dB diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index eca3f19249..0280becc7e 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -961,6 +961,11 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { config.scan_time.active.min = 100; config.scan_time.active.max = 300; } + // When scanning while connected (roaming), return to home channel between + // each scanned channel to maintain the connection (helps with BLE/WiFi coexistence) + if (this->roaming_state_ == RoamingState::SCANNING) { + config.coex_background_scan = true; + } esp_err_t err = esp_wifi_scan_start(&config, false); if (err != ESP_OK) { From 7b5a4b466a135ab658317e928830a65c05f9d19a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:50:37 -0400 Subject: [PATCH 131/160] [uart] Fix debug callback missing peeked byte and reading past end (#15169) --- esphome/components/uart/uart_component_esp_idf.cpp | 6 ++++-- esphome/components/uart/uart_component_host.cpp | 6 ++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 8168e49805..82120b1a5f 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -324,6 +324,9 @@ bool IDFUARTComponent::peek_byte(uint8_t *data) { } bool IDFUARTComponent::read_array(uint8_t *data, size_t len) { + if (len == 0) { + return false; + } size_t length_to_read = len; int32_t read_len = 0; if (!this->check_read_timeout_(len)) @@ -331,11 +334,10 @@ bool IDFUARTComponent::read_array(uint8_t *data, size_t len) { if (this->has_peek_) { length_to_read--; *data = this->peek_byte_; - data++; this->has_peek_ = false; } if (length_to_read > 0) - read_len = uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS); + read_len = uart_read_bytes(this->uart_num_, data + (len - length_to_read), length_to_read, 20 / portTICK_PERIOD_MS); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { this->debug_callback_.call(UART_DIRECTION_RX, data[i]); diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 66026f3ccd..e9c101816e 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -235,16 +235,14 @@ bool HostUartComponent::read_array(uint8_t *data, size_t len) { } if (!this->check_read_timeout_(len)) return false; - uint8_t *data_ptr = data; size_t length_to_read = len; if (this->has_peek_) { length_to_read--; - *data_ptr = this->peek_byte_; - data_ptr++; + *data = this->peek_byte_; this->has_peek_ = false; } if (length_to_read > 0) { - int sz = ::read(this->file_descriptor_, data_ptr, length_to_read); + int sz = ::read(this->file_descriptor_, data + (len - length_to_read), length_to_read); if (sz == -1) { this->update_error_(strerror(errno)); return false; From 3fd3dcc7e575e52f0924ebdd4fa2fa0356762c0c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:13:24 -0400 Subject: [PATCH 132/160] [sgp4x] Fix NOx index_offset default (should be 1, not 100) (#15212) --- esphome/components/sgp4x/sensor.py | 39 ++++++++++++++++++------------ 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index ab78ab59d9..8d52ffb4f2 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -44,20 +44,27 @@ def validate_sensors(config): return config -GAS_SENSOR = cv.Schema( - { - cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema( - { - cv.Optional(CONF_INDEX_OFFSET, default=100): cv.int_, - cv.Optional(CONF_LEARNING_TIME_OFFSET_HOURS, default=12): cv.int_, - cv.Optional(CONF_LEARNING_TIME_GAIN_HOURS, default=12): cv.int_, - cv.Optional(CONF_GATING_MAX_DURATION_MINUTES, default=720): cv.int_, - cv.Optional(CONF_STD_INITIAL, default=50): cv.int_, - cv.Optional(CONF_GAIN_FACTOR, default=230): cv.int_, - } - ) - } -) +def _gas_sensor_schema(index_offset_default: int): + return cv.Schema( + { + cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema( + { + cv.Optional( + CONF_INDEX_OFFSET, default=index_offset_default + ): cv.int_, + cv.Optional(CONF_LEARNING_TIME_OFFSET_HOURS, default=12): cv.int_, + cv.Optional(CONF_LEARNING_TIME_GAIN_HOURS, default=12): cv.int_, + cv.Optional(CONF_GATING_MAX_DURATION_MINUTES, default=720): cv.int_, + cv.Optional(CONF_STD_INITIAL, default=50): cv.int_, + cv.Optional(CONF_GAIN_FACTOR, default=230): cv.int_, + } + ) + } + ) + + +VOC_SENSOR = _gas_sensor_schema(100) +NOX_SENSOR = _gas_sensor_schema(1) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -68,13 +75,13 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, - ).extend(GAS_SENSOR), + ).extend(VOC_SENSOR), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, - ).extend(GAS_SENSOR), + ).extend(NOX_SENSOR), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, cv.Optional(CONF_VOC_BASELINE): cv.hex_uint16_t, cv.Optional(CONF_COMPENSATION): cv.Schema( From 3d8a3a91f25cc2e620c4d04e3b5aa9fd1773921b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 15:38:06 -1000 Subject: [PATCH 133/160] [esp32_ble_server] Fix set_value action with static data lists (#15285) --- .../components/esp32_ble_server/ble_server_automations.h | 2 ++ tests/components/esp32_ble_server/common.yaml | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index fe18600280..0bbfdffd5b 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -70,6 +70,7 @@ template class BLECharacteristicSetValueAction : public Action, buffer) + void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } void play(const Ts &...x) override { // If the listener is already set, do nothing @@ -115,6 +116,7 @@ template class BLEDescriptorSetValueAction : public Action, buffer) + void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } void play(const Ts &...x) override { this->parent_->set_value(this->buffer_.value(x...)); } diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index 7fe0b2eb5f..4e34049038 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -69,3 +69,11 @@ esp32_ble_server: - ble_server.descriptor.set_value: id: test_change_descriptor value: !lambda return bytebuffer::ByteBuffer::wrap({0x03, 0x04, 0x05}).get_data(); + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: + data: [0xfc, 0xef, 0xfe, 0x86] + - ble_server.descriptor.set_value: + id: test_change_descriptor + value: + data: [0x01, 0x02, 0x03] From d79cf1d7183ed1b72a825a116be798120bbd775e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:57:52 -1000 Subject: [PATCH 134/160] [esp8266] Add enable_scanf_float option (#15284) --- esphome/components/esp8266/__init__.py | 62 +++++++++++++++---- .../components/esp8266/test.esp8266-ard.yaml | 3 + tests/unit_tests/components/test_esp8266.py | 62 +++++++++++++++++++ 3 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 tests/unit_tests/components/test_esp8266.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 16043b6d69..2081145096 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +import re import esphome.codegen as cg import esphome.config_validation as cv @@ -18,8 +19,9 @@ from esphome.const import ( PLATFORM_ESP8266, ThreadModel, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority from esphome.helpers import copy_file_if_changed +from esphome.types import ConfigType from .boards import BOARDS, ESP8266_LD_SCRIPTS from .const import ( @@ -40,12 +42,42 @@ from .const import ( ) from .gpio import PinInitialState, add_pin_initial_states_array +CONF_ENABLE_SCANF_FLOAT = "enable_scanf_float" +# Heuristically matches scanf/sscanf calls with float format specifiers. +# Standard scanf float conversions: %f %F %e %E %g %G %a %A +# With optional modifiers: %*f (suppression), %8f (width), %lf %Lf (length) +# Also matches non-standard patterns like %.2f as a heuristic — these are +# invalid in scanf but users may write them by analogy with printf. +# Uses [^;]*? to stay within a single statement, preventing false positives +# from e.g. sscanf(buf, "%d", &x); printf("%f", val); +_SCANF_FLOAT_RE = re.compile(r"scanf\s*\([^;]*?%[*\d.]*[hlL]*[feEgGaAF]") + CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["preferences"] IS_TARGET_PLATFORM = True +def lambdas_use_scanf_float(config: ConfigType) -> bool: + """Check if any lambda in the config uses scanf with a float format specifier. + + Comments are stripped before matching to avoid false positives from + commented-out code. The cost of a false positive is only ~8KB flash. + """ + stack: list = [config] + while stack: + obj = stack.pop() + if isinstance(obj, Lambda): + src = obj.comment_remover(obj.value) + if _SCANF_FLOAT_RE.search(src): + return True + elif isinstance(obj, dict): + stack.extend(obj.values()) + elif isinstance(obj, list): + stack.extend(obj) + return False + + def set_core_data(config): CORE.data[KEY_ESP8266] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266 @@ -181,6 +213,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_SERIAL): cv.boolean, cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean, cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, + cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean, } ), set_core_data, @@ -201,16 +234,23 @@ async def to_code(config): cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define(ThreadModel.SINGLE) - cg.add_platformio_option( - "extra_scripts", - [ - "pre:testing_mode.py", - "pre:exclude_updater.py", - "pre:exclude_waveform.py", - "pre:remove_float_scanf.py", - "post:post_build.py", - ], - ) + enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT) + if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config): + enable_scanf_float = True + _LOGGER.warning( + "Lambda uses scanf with a float format specifier; " + "enabling scanf float support (~8KB flash)" + ) + + extra_scripts = [ + "pre:testing_mode.py", + "pre:exclude_updater.py", + "pre:exclude_waveform.py", + ] + if not enable_scanf_float: + extra_scripts.append("pre:remove_float_scanf.py") + extra_scripts.append("post:post_build.py") + cg.add_platformio_option("extra_scripts", extra_scripts) conf = config[CONF_FRAMEWORK] cg.add_platformio_option("framework", "arduino") diff --git a/tests/components/esp8266/test.esp8266-ard.yaml b/tests/components/esp8266/test.esp8266-ard.yaml index c77218f7a3..ba70c1a6a4 100644 --- a/tests/components/esp8266/test.esp8266-ard.yaml +++ b/tests/components/esp8266/test.esp8266-ard.yaml @@ -14,3 +14,6 @@ esphome: assert(x == 95); x = clamp_at_most(x, 40); assert(x == 40); + - lambda: |- + float value = 0.0f; + sscanf("3.14", "%f", &value); diff --git a/tests/unit_tests/components/test_esp8266.py b/tests/unit_tests/components/test_esp8266.py new file mode 100644 index 0000000000..318fd2d889 --- /dev/null +++ b/tests/unit_tests/components/test_esp8266.py @@ -0,0 +1,62 @@ +"""Tests for ESP8266 component.""" + +import pytest + +from esphome.components.esp8266 import lambdas_use_scanf_float +from esphome.core import Lambda +from esphome.types import ConfigType + + +@pytest.mark.parametrize( + ("src", "expected"), + [ + # Basic float formats + ('sscanf(buf, "%f", &v)', True), + ('sscanf(buf, "%F", &v)', True), + ('sscanf(buf, "%e", &v)', True), + ('sscanf(buf, "%E", &v)', True), + ('sscanf(buf, "%g", &v)', True), + ('sscanf(buf, "%G", &v)', True), + ('sscanf(buf, "%a", &v)', True), + ('sscanf(buf, "%A", &v)', True), + # With modifiers + ('sscanf(buf, "%lf", &v)', True), + ('sscanf(buf, "%Lf", &v)', True), + ('sscanf(buf, "%8lf", &v)', True), + ('sscanf(buf, "%*f")', True), + ('sscanf(buf, "%.2f", &v)', True), + # Mixed formats + ('sscanf(buf, "%d,%f", &a, &b)', True), + # fscanf and std::sscanf + ('fscanf(fp, "%f", &v)', True), + ('std::sscanf(buf, "%f", &v)', True), + # Multi-line + ('sscanf(buf,\n"%f", &v)', True), + # No float format + ('sscanf(buf, "%d", &v)', False), + ('sscanf(buf, "%s", s)', False), + # printf not scanf + ('printf("%f", val)', False), + # %f in a different statement after scanf + ('sscanf(buf, "%d", &x); printf("%f", val);', False), + # scanf %f in comment only + ('// sscanf(buf, "%f", &v)\nsscanf(buf, "%d", &x)', False), + ('/* sscanf(buf, "%f") */\nsscanf(buf, "%d", &x)', False), + ], +) +def test_lambdas_use_scanf_float(src: str, expected: bool) -> None: + """Test scanf float detection in lambda source.""" + config: ConfigType = {"test": [Lambda(src)]} + assert lambdas_use_scanf_float(config) is expected + + +def test_lambdas_use_scanf_float_no_lambdas() -> None: + """Test with config containing no lambdas.""" + config: ConfigType = {"key": "value", "list": [1, 2]} + assert lambdas_use_scanf_float(config) is False + + +def test_lambdas_use_scanf_float_nested() -> None: + """Test detection in deeply nested config.""" + config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}} + assert lambdas_use_scanf_float(config) is True From 9cd7c5e700f954272742250467b2ad703d6f5e60 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 30 Mar 2026 13:15:02 -0500 Subject: [PATCH 135/160] [thermostat] Fix stale `max_runtime_exceeded` causing spurious supplemental heating/cooling (#15274) --- .../components/thermostat/thermostat_climate.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index d52a22f880..eb3e756bc2 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -606,6 +606,16 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu } void ThermostatClimate::switch_to_supplemental_action_(climate::ClimateAction action) { + // Always cancel max-runtime timers and clear exceeded flags when transitioning to idle/off, + // even if supplemental_action_ is already idle (early-return path). This prevents a stale + // heating_max_runtime_exceeded_ flag from triggering supplemental on the next heating cycle + // when HEATING_MAX_RUN_TIME fires while the main action is already IDLE. + if (action == climate::CLIMATE_ACTION_OFF || action == climate::CLIMATE_ACTION_IDLE) { + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); + this->cooling_max_runtime_exceeded_ = false; + this->heating_max_runtime_exceeded_ = false; + } // setup_complete_ helps us ensure an action is called immediately after boot if ((action == this->supplemental_action_) && this->setup_complete_) { // already in target mode @@ -975,8 +985,10 @@ void ThermostatClimate::cooling_on_timer_callback_() { void ThermostatClimate::fan_mode_timer_callback_() { ESP_LOGVV(TAG, "fan_mode timer expired"); this->switch_to_fan_mode_(this->fan_mode.value_or(climate::CLIMATE_FAN_ON)); - if (this->supports_fan_only_action_uses_fan_mode_timer_) + if (this->supports_fan_only_action_uses_fan_mode_timer_) { this->switch_to_action_(this->compute_action_()); + this->switch_to_supplemental_action_(this->compute_supplemental_action_()); + } } void ThermostatClimate::fanning_off_timer_callback_() { From 3bf45d8fe026309f8a8e60f49bdb15c564788529 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:22:29 -0400 Subject: [PATCH 136/160] [haier] Fix hOn half-degree temperature setting (#15312) --- esphome/components/haier/hon_climate.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index b8889ef2bd..b027b0f295 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -677,7 +677,6 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { this->quiet_mode_state_ = (SwitchState) ((uint8_t) this->quiet_mode_state_ & 0b01); } out_data->beeper_status = ((!this->get_beeper_state()) || (!has_hvac_settings)) ? 1 : 0; - control_out_buffer[4] = 0; // This byte should be cleared before setting values out_data->display_status = this->get_display_state() ? 1 : 0; this->display_status_ = (SwitchState) ((uint8_t) this->display_status_ & 0b01); out_data->health_mode = this->get_health_mode() ? 1 : 0; From 66a4acafd061b37f71f49cd7c6348df9a7c9eb9e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:01:33 -0400 Subject: [PATCH 137/160] [tormatic] Fix UART stream desync on ESP32 (#15337) --- .../components/tormatic/tormatic_cover.cpp | 67 ++++++++++++++----- esphome/components/tormatic/tormatic_cover.h | 1 + 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp index 37a269088e..a48dece840 100644 --- a/esphome/components/tormatic/tormatic_cover.cpp +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -9,6 +9,10 @@ namespace tormatic { static const char *const TAG = "tormatic.cover"; +// Time to poll the UART when flushing after desync. At 9600 baud, a full +// 12-byte message takes ~12.5ms, so 15ms guarantees all bytes have arrived. +static constexpr uint32_t DRAIN_TIMEOUT_MS = 15; + using namespace esphome::cover; void Tormatic::setup() { @@ -255,32 +259,51 @@ void Tormatic::stop_at_target_() { // Read a GateStatus from the unit. The unit only sends messages in response to // status requests or commands, so a message needs to be sent first. optional Tormatic::read_gate_status_() { - if (this->available() < sizeof(MessageHeader)) { + if (!this->pending_hdr_) { + if (this->available() < sizeof(MessageHeader)) { + return {}; + } + + this->pending_hdr_ = this->read_data_(); + if (!this->pending_hdr_) { + return {}; + } + + // Sanity check: valid messages have small payloads (3-4 bytes). A large + // or impossible payload_size means the stream is out of sync (corrupted + // byte, dropped data, etc.). Flush the buffer so we can resync on the + // next request/response cycle. + if (this->pending_hdr_->payload_size() > sizeof(CommandRequestReply)) { + ESP_LOGW(TAG, "Unexpected payload size %" PRIu32 ", flushing rx buffer", this->pending_hdr_->payload_size()); + this->pending_hdr_.reset(); + this->drain_rx_(); + return {}; + } + } + + // Wait for all payload bytes to arrive before processing. + if (this->available() < this->pending_hdr_->payload_size()) { return {}; } - auto o_hdr = this->read_data_(); - if (!o_hdr) { - ESP_LOGE(TAG, "Timeout reading message header"); - return {}; - } - auto hdr = o_hdr.value(); + auto hdr = *this->pending_hdr_; + this->pending_hdr_.reset(); switch (hdr.type) { case STATUS: { if (hdr.payload_size() != sizeof(StatusReply)) { ESP_LOGE(TAG, "Header specifies payload size %d but size of StatusReply is %d", hdr.payload_size(), sizeof(StatusReply)); + this->drain_rx_(hdr.payload_size()); + return {}; } - // Read a StatusReply requested by update(). auto o_status = this->read_data_(); if (!o_status) { return {}; } - auto status = o_status.value(); - return status.state; + return o_status->state; } case COMMAND: @@ -343,16 +366,24 @@ template optional Tormatic::read_data_() { return obj; } -// Drain up to n amount of bytes from the uart rx buffer. +// Drain bytes from the uart rx buffer. When n > 0, drain exactly n bytes +// (caller must ensure they are available). When n == 0, poll for 15ms to +// guarantee a full packet time at 9600 baud has elapsed, consuming any +// bytes still in transit. void Tormatic::drain_rx_(uint16_t n) { uint8_t data; - uint16_t count = 0; - while (this->available()) { - this->read_byte(&data); - count++; - - if (n > 0 && count >= n) { - return; + if (n > 0) { + for (uint16_t i = 0; i < n; i++) { + if (!this->read_byte(&data)) { + return; + } + } + } else { + uint32_t start = millis(); + while (millis() - start < DRAIN_TIMEOUT_MS) { + if (this->available()) { + this->read_byte(&data); + } } } } diff --git a/esphome/components/tormatic/tormatic_cover.h b/esphome/components/tormatic/tormatic_cover.h index 534d4bef14..34483ed6a3 100644 --- a/esphome/components/tormatic/tormatic_cover.h +++ b/esphome/components/tormatic/tormatic_cover.h @@ -43,6 +43,7 @@ class Tormatic : public cover::Cover, public uart::UARTDevice, public PollingCom void handle_gate_status_(GateStatus s); uint32_t seq_tx_{0}; + optional pending_hdr_{}; GateStatus current_status_{PAUSED}; From dc634b8c7b5218b9b5b0a9c561ffa4a80254bb57 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:04:07 +0200 Subject: [PATCH 138/160] [uart] fix baud rate not applied on `load_settings()` for ESP32 (IDF) (#15341) --- .../uart/uart_component_esp_idf.cpp | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 82120b1a5f..7d02f54b47 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -147,6 +147,20 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + // uart_param_config must be called after uart_driver_install and before any + // other uart_set_*() calls. The driver installation resets the UART peripheral + // registers to their default state, overwriting any previously configured baud + // rate or framing settings. Calling uart_param_config here ensures the requested + // settings are applied after the reset and before pin routing, inversion, and + // threshold configuration. + uart_config_t uart_config = this->get_config_(); + err = uart_param_config(this->uart_num_, &uart_config); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; @@ -214,22 +228,15 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; - err = uart_set_mode(this->uart_num_, mode); // per docs, must be called only after uart_driver_install() + err = uart_set_mode(this->uart_num_, mode); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); this->mark_failed(); return; } - uart_config_t uart_config = this->get_config_(); - err = uart_param_config(this->uart_num_, &uart_config); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - #ifdef USE_UART_WAKE_LOOP_ON_RX // Register ISR callback to wake the main loop when UART data arrives. // The callback runs in ISR context and uses vTaskNotifyGiveFromISR() to From 514c0c8331c6eebc7e116b85c7d8abe2ecc6526b Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 31 Mar 2026 19:06:48 -0400 Subject: [PATCH 139/160] [mixer] Fix memory leak in mixer task on stop/start cycles (#15185) --- .../mixer/speaker/mixer_speaker.cpp | 274 +++++++++--------- 1 file changed, 137 insertions(+), 137 deletions(-) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 9d11abb327..0fabc68c70 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -597,173 +597,173 @@ void MixerSpeaker::audio_mixer_task(void *params) { xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STARTING); - std::unique_ptr output_transfer_buffer = audio::AudioSinkTransferBuffer::create( - this_mixer->audio_stream_info_.value().ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); + { // Ensure C++ objects fall out of scope to ensure proper cleanup before stopping the task + std::unique_ptr output_transfer_buffer = audio::AudioSinkTransferBuffer::create( + this_mixer->audio_stream_info_.value().ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); - if (output_transfer_buffer == nullptr) { - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED | MIXER_TASK_ERR_ESP_NO_MEM); + if (output_transfer_buffer == nullptr) { + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED | MIXER_TASK_ERR_ESP_NO_MEM); - vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it - } - - output_transfer_buffer->set_sink(this_mixer->output_speaker_); - - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_RUNNING); - - bool sent_finished = false; - - // Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema) - FixedVector speakers_with_data; - FixedVector> transfer_buffers_with_data; - speakers_with_data.init(this_mixer->source_speakers_.size()); - transfer_buffers_with_data.init(this_mixer->source_speakers_.size()); - - while (true) { - uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_); - if (event_group_bits & MIXER_TASK_COMMAND_STOP) { - break; + vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } - // Never shift the data in the output transfer buffer to avoid unnecessary, slow data moves - output_transfer_buffer->transfer_data_to_sink(pdMS_TO_TICKS(TASK_DELAY_MS), false); + output_transfer_buffer->set_sink(this_mixer->output_speaker_); - const uint32_t output_frames_free = - this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free()); + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_RUNNING); - speakers_with_data.clear(); - transfer_buffers_with_data.clear(); + bool sent_finished = false; - for (auto &speaker : this_mixer->source_speakers_) { - if (speaker->is_running() && !speaker->get_pause_state()) { - // Speaker is running and not paused, so it possibly can provide audio data - std::shared_ptr transfer_buffer = speaker->get_transfer_buffer().lock(); - if (transfer_buffer.use_count() == 0) { - // No transfer buffer allocated, so skip processing this speaker - continue; - } - speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers + // Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema) + FixedVector speakers_with_data; + FixedVector> transfer_buffers_with_data; + speakers_with_data.init(this_mixer->source_speakers_.size()); + transfer_buffers_with_data.init(this_mixer->source_speakers_.size()); - if (transfer_buffer->available() > 0) { - // Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop - transfer_buffers_with_data.push_back(transfer_buffer); - speakers_with_data.push_back(speaker); + while (true) { + uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_); + if (event_group_bits & MIXER_TASK_COMMAND_STOP) { + break; + } + + // Never shift the data in the output transfer buffer to avoid unnecessary, slow data moves + output_transfer_buffer->transfer_data_to_sink(pdMS_TO_TICKS(TASK_DELAY_MS), false); + + const uint32_t output_frames_free = + this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free()); + + speakers_with_data.clear(); + transfer_buffers_with_data.clear(); + + for (auto &speaker : this_mixer->source_speakers_) { + if (speaker->is_running() && !speaker->get_pause_state()) { + // Speaker is running and not paused, so it possibly can provide audio data + std::shared_ptr transfer_buffer = speaker->get_transfer_buffer().lock(); + if (transfer_buffer.use_count() == 0) { + // No transfer buffer allocated, so skip processing this speaker + continue; + } + speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers + + if (transfer_buffer->available() > 0) { + // Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop + transfer_buffers_with_data.push_back(transfer_buffer); + speakers_with_data.push_back(speaker); + } } } - } - if (transfer_buffers_with_data.empty()) { - // No audio available for transferring, block task temporarily - delay(TASK_DELAY_MS); - continue; - } + if (transfer_buffers_with_data.empty()) { + // No audio available for transferring, block task temporarily + delay(TASK_DELAY_MS); + continue; + } - uint32_t frames_to_mix = output_frames_free; + uint32_t frames_to_mix = output_frames_free; - if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) { - // Only one speaker has audio data, just copy samples over + if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) { + // Only one speaker has audio data, just copy samples over - audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info(); + audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info(); - if (active_stream_info.get_sample_rate() == - this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) { - // Speaker's sample rate matches the output speaker's, copy directly + if (active_stream_info.get_sample_rate() == + this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) { + // Speaker's sample rate matches the output speaker's, copy directly - const uint32_t frames_available_in_buffer = - active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available()); - frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - copy_frames(reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()), active_stream_info, - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); + const uint32_t frames_available_in_buffer = + active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available()); + frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); + copy_frames(reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()), + active_stream_info, reinterpret_cast(output_transfer_buffer->get_buffer_end()), + this_mixer->audio_stream_info_.value(), frames_to_mix); - // Set playback delay for newly contributing source - if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { - speakers_with_data[0]->playback_delay_frames_.store( - this_mixer->frames_in_pipeline_.load(std::memory_order_acquire), std::memory_order_release); - speakers_with_data[0]->has_contributed_.store(true, std::memory_order_release); + // Set playback delay for newly contributing source + if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { + speakers_with_data[0]->playback_delay_frames_.store( + this_mixer->frames_in_pipeline_.load(std::memory_order_acquire), std::memory_order_release); + speakers_with_data[0]->has_contributed_.store(true, std::memory_order_release); + } + + // Update source speaker pending frames + speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); + transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix)); + + // Update output transfer buffer length and pipeline frame count + output_transfer_buffer->increase_buffer_length( + this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); + } else { + // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker + if (!this_mixer->output_speaker_->is_stopped()) { + if (!sent_finished) { + this_mixer->output_speaker_->finish(); + sent_finished = true; // Avoid repeatedly sending the finish command + } + } else { + // Speaker has finished writing the current audio, update the stream information and restart the speaker + this_mixer->audio_stream_info_ = + audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_, + active_stream_info.get_sample_rate()); + this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); + this_mixer->output_speaker_->start(); + // Reset pipeline frame count since we're starting fresh with a new sample rate + this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); + sent_finished = false; + } + } + } else { + // Determine how many frames to mix + for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { + const uint32_t frames_available_in_buffer = speakers_with_data[i]->get_audio_stream_info().bytes_to_frames( + transfer_buffers_with_data[i]->available()); + frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); + } + int16_t *primary_buffer = reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()); + audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); + + // Mix two streams together + for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) { + mix_audio_samples(primary_buffer, primary_stream_info, + reinterpret_cast(transfer_buffers_with_data[i]->get_buffer_start()), + speakers_with_data[i]->get_audio_stream_info(), + reinterpret_cast(output_transfer_buffer->get_buffer_end()), + this_mixer->audio_stream_info_.value(), frames_to_mix); + + if (i != transfer_buffers_with_data.size() - 1) { + // Need to mix more streams together, point primary buffer and stream info to the already mixed output + primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); + primary_stream_info = this_mixer->audio_stream_info_.value(); + } } - // Update source speaker pending frames - speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); - transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix)); + // Get current pipeline depth for delay calculation (before incrementing) + uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire); - // Update output transfer buffer length and pipeline frame count + // Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks + for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { + // Set playback delay for newly contributing sources + if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) { + speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release); + speakers_with_data[i]->has_contributed_.store(true, std::memory_order_release); + } + + speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); + transfer_buffers_with_data[i]->decrease_buffer_length( + speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix)); + } + + // Update output transfer buffer length and pipeline frame count (once, not per source) output_transfer_buffer->increase_buffer_length( this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); - } else { - // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker - if (!this_mixer->output_speaker_->is_stopped()) { - if (!sent_finished) { - this_mixer->output_speaker_->finish(); - sent_finished = true; // Avoid repeatedly sending the finish command - } - } else { - // Speaker has finished writing the current audio, update the stream information and restart the speaker - this_mixer->audio_stream_info_ = - audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_, - active_stream_info.get_sample_rate()); - this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); - this_mixer->output_speaker_->start(); - // Reset pipeline frame count since we're starting fresh with a new sample rate - this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); - sent_finished = false; - } } - } else { - // Determine how many frames to mix - for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { - const uint32_t frames_available_in_buffer = - speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(transfer_buffers_with_data[i]->available()); - frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - } - int16_t *primary_buffer = reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()); - audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); - - // Mix two streams together - for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) { - mix_audio_samples(primary_buffer, primary_stream_info, - reinterpret_cast(transfer_buffers_with_data[i]->get_buffer_start()), - speakers_with_data[i]->get_audio_stream_info(), - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); - - if (i != transfer_buffers_with_data.size() - 1) { - // Need to mix more streams together, point primary buffer and stream info to the already mixed output - primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); - primary_stream_info = this_mixer->audio_stream_info_.value(); - } - } - - // Get current pipeline depth for delay calculation (before incrementing) - uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire); - - // Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks - for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { - // Set playback delay for newly contributing sources - if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) { - speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release); - speakers_with_data[i]->has_contributed_.store(true, std::memory_order_release); - } - - speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); - transfer_buffers_with_data[i]->decrease_buffer_length( - speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix)); - } - - // Update output transfer buffer length and pipeline frame count (once, not per source) - output_transfer_buffer->increase_buffer_length( - this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); - this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } - } - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPING); + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPING); + } // Reset pipeline frame count since the task is stopping this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); - output_transfer_buffer.reset(); - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED); vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it From 65051153ac0f7559bd8e271d82fb9685b611164e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 13:26:26 -1000 Subject: [PATCH 140/160] [esp32_ble_tracker] Restart BLE scan after OTA failure (#15308) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 6 ++++++ esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 3 +++ 2 files changed, 9 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 5a43cf7e49..6a2834a869 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -82,12 +82,18 @@ void ESP32BLETracker::setup() { #ifdef USE_OTA_STATE_LISTENER void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { if (state == ota::OTA_STARTED) { + this->scan_continuous_before_ota_ = this->scan_continuous_; this->stop_scan(); #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT for (auto *client : this->clients_) { client->disconnect(); } #endif + } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { + this->scan_continuous_before_ota_ = false; + this->scan_continuous_ = true; + // Do not restart scanning immediately here; allow loop() to + // safely restart scanning once the scanner and all clients are idle. } } #endif diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 7f1c2b0f7c..e0e25aca20 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -429,6 +429,9 @@ class ESP32BLETracker : public Component, ScannerState scanner_state_{ScannerState::IDLE}; bool scan_continuous_; bool scan_active_; +#ifdef USE_OTA_STATE_LISTENER + bool scan_continuous_before_ota_{false}; +#endif bool ble_was_disabled_{true}; bool raw_advertisements_{false}; bool parse_advertisements_{false}; From 600ca01fd3904fffd3d6092a45c8a3f6a1899c1b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:18:24 +1300 Subject: [PATCH 141/160] Bump version to 2026.3.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index d86894435f..97201d1c44 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.3.1 +PROJECT_NUMBER = 2026.3.2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 52ac7acd22..ebab56193c 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.1" +__version__ = "2026.3.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 8f2cf8b8a75ed710559eff51a37097bc2100959a Mon Sep 17 00:00:00 2001 From: Christian H <28529536+nytaros@users.noreply.github.com> Date: Wed, 1 Apr 2026 03:39:41 +0200 Subject: [PATCH 142/160] [bmp581_base] Add support for BMP585 (#15277) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/bmp581_base/bmp581_base.cpp | 2 +- esphome/components/bmp581_base/bmp581_base.h | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index c9d250545b..7a627eee03 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -126,7 +126,7 @@ void BMP581Component::setup() { } // verify id - if (chip_id != BMP581_ASIC_ID) { + if (chip_id != BMP581_ASIC_ID && chip_id != BMP585_ASIC_ID) { ESP_LOGE(TAG, "Unknown chip ID"); this->error_code_ = ERROR_WRONG_CHIP_ID; diff --git a/esphome/components/bmp581_base/bmp581_base.h b/esphome/components/bmp581_base/bmp581_base.h index c3920512e0..1a73a91558 100644 --- a/esphome/components/bmp581_base/bmp581_base.h +++ b/esphome/components/bmp581_base/bmp581_base.h @@ -8,7 +8,8 @@ namespace esphome::bmp581_base { static const uint8_t BMP581_ASIC_ID = 0x50; // BMP581's ASIC chip ID (page 51 of datasheet) -static const uint8_t RESET_COMMAND = 0xB6; // Soft reset command +static const uint8_t BMP585_ASIC_ID = 0x51; +static const uint8_t RESET_COMMAND = 0xB6; // Soft reset command // BMP581 Register Addresses enum { From 31a70ab29911d646dc602a0df012d73ba6c85f9b Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 31 Mar 2026 21:44:54 -0400 Subject: [PATCH 143/160] [resampler] Future-proof resampler task to avoid potential memory leaks (#15186) --- .../resampler/speaker/resampler_speaker.cpp | 86 ++++++++++--------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index 1303bc459e..b737a2d39a 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -317,57 +317,59 @@ void ResamplerSpeaker::resample_task(void *params) { xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::STATE_STARTING); - std::unique_ptr resampler = - make_unique(this_resampler->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS), - this_resampler->target_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); + { // Ensure C++ objects fall out of scope for proper cleanup before stopping the task + std::unique_ptr resampler = make_unique( + this_resampler->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS), + this_resampler->target_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); - esp_err_t err = resampler->start(this_resampler->audio_stream_info_, this_resampler->target_stream_info_, - this_resampler->taps_, this_resampler->filters_); + esp_err_t err = resampler->start(this_resampler->audio_stream_info_, this_resampler->target_stream_info_, + this_resampler->taps_, this_resampler->filters_); - if (err == ESP_OK) { - std::shared_ptr temp_ring_buffer = - RingBuffer::create(this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_)); + if (err == ESP_OK) { + std::shared_ptr temp_ring_buffer = + RingBuffer::create(this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_)); - if (!temp_ring_buffer) { - err = ESP_ERR_NO_MEM; - } else { - this_resampler->ring_buffer_ = temp_ring_buffer; - resampler->add_source(this_resampler->ring_buffer_); + if (!temp_ring_buffer) { + err = ESP_ERR_NO_MEM; + } else { + this_resampler->ring_buffer_ = temp_ring_buffer; + resampler->add_source(this_resampler->ring_buffer_); - this_resampler->output_speaker_->set_audio_stream_info(this_resampler->target_stream_info_); - resampler->add_sink(this_resampler->output_speaker_); - } - } - - if (err == ESP_OK) { - xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::STATE_RUNNING); - } else if (err == ESP_ERR_NO_MEM) { - xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::ERR_ESP_NO_MEM); - } else if (err == ESP_ERR_NOT_SUPPORTED) { - xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::ERR_ESP_NOT_SUPPORTED); - } - - while (err == ESP_OK) { - uint32_t event_bits = xEventGroupGetBits(this_resampler->event_group_); - - if (event_bits & ResamplingEventGroupBits::TASK_COMMAND_STOP) { - break; + this_resampler->output_speaker_->set_audio_stream_info(this_resampler->target_stream_info_); + resampler->add_sink(this_resampler->output_speaker_); + } } - // Stop gracefully if the decoder is done - int32_t ms_differential = 0; - audio::AudioResamplerState resampler_state = resampler->resample(false, &ms_differential); - - if (resampler_state == audio::AudioResamplerState::FINISHED) { - break; - } else if (resampler_state == audio::AudioResamplerState::FAILED) { - xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::ERR_ESP_FAIL); - break; + if (err == ESP_OK) { + xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::STATE_RUNNING); + } else if (err == ESP_ERR_NO_MEM) { + xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::ERR_ESP_NO_MEM); + } else if (err == ESP_ERR_NOT_SUPPORTED) { + xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::ERR_ESP_NOT_SUPPORTED); } + + while (err == ESP_OK) { + uint32_t event_bits = xEventGroupGetBits(this_resampler->event_group_); + + if (event_bits & ResamplingEventGroupBits::TASK_COMMAND_STOP) { + break; + } + + // Stop gracefully if the decoder is done + int32_t ms_differential = 0; + audio::AudioResamplerState resampler_state = resampler->resample(false, &ms_differential); + + if (resampler_state == audio::AudioResamplerState::FINISHED) { + break; + } else if (resampler_state == audio::AudioResamplerState::FAILED) { + xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::ERR_ESP_FAIL); + break; + } + } + + xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::STATE_STOPPING); } - xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::STATE_STOPPING); - resampler.reset(); xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::STATE_STOPPED); vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it From 212b3e16880808ab7e3af1b6a5f513a9f622b2fb Mon Sep 17 00:00:00 2001 From: Rene Guca <45061891+rguca@users.noreply.github.com> Date: Wed, 1 Apr 2026 03:59:24 +0200 Subject: [PATCH 144/160] [cover] move time_based_cover to its own subdirectory (#15313) Co-authored-by: Rene --- esphome/components/time_based/__init__.py | 3 +++ esphome/components/time_based/{cover.py => cover/__init__.py} | 3 ++- esphome/components/time_based/{ => cover}/time_based_cover.cpp | 0 esphome/components/time_based/{ => cover}/time_based_cover.h | 0 4 files changed, 5 insertions(+), 1 deletion(-) rename esphome/components/time_based/{cover.py => cover/__init__.py} (97%) rename esphome/components/time_based/{ => cover}/time_based_cover.cpp (100%) rename esphome/components/time_based/{ => cover}/time_based_cover.h (100%) diff --git a/esphome/components/time_based/__init__.py b/esphome/components/time_based/__init__.py index e69de29bb2..ce2f453bda 100644 --- a/esphome/components/time_based/__init__.py +++ b/esphome/components/time_based/__init__.py @@ -0,0 +1,3 @@ +import esphome.codegen as cg + +time_based_ns = cg.esphome_ns.namespace("time_based") diff --git a/esphome/components/time_based/cover.py b/esphome/components/time_based/cover/__init__.py similarity index 97% rename from esphome/components/time_based/cover.py rename to esphome/components/time_based/cover/__init__.py index d14332d453..022b48d249 100644 --- a/esphome/components/time_based/cover.py +++ b/esphome/components/time_based/cover/__init__.py @@ -11,7 +11,8 @@ from esphome.const import ( CONF_STOP_ACTION, ) -time_based_ns = cg.esphome_ns.namespace("time_based") +from .. import time_based_ns + TimeBasedCover = time_based_ns.class_("TimeBasedCover", cover.Cover, cg.Component) CONF_HAS_BUILT_IN_ENDSTOP = "has_built_in_endstop" diff --git a/esphome/components/time_based/time_based_cover.cpp b/esphome/components/time_based/cover/time_based_cover.cpp similarity index 100% rename from esphome/components/time_based/time_based_cover.cpp rename to esphome/components/time_based/cover/time_based_cover.cpp diff --git a/esphome/components/time_based/time_based_cover.h b/esphome/components/time_based/cover/time_based_cover.h similarity index 100% rename from esphome/components/time_based/time_based_cover.h rename to esphome/components/time_based/cover/time_based_cover.h From fbfb5d401f99cf40d60bdced6db36ba00262f27e Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:34:29 +0200 Subject: [PATCH 145/160] [nextion] Fix memory leak in `reset_()` (#15344) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/nextion/nextion.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index bb3e12be50..d141ef7906 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -143,8 +143,17 @@ void Nextion::reset_(bool reset_nextion) { while (this->available()) { // Clear receive buffer this->read_byte(&d); - }; + } + for (auto *entry : this->nextion_queue_) { + if (entry->component != nullptr && entry->component->get_queue_type() == NextionQueueType::NO_RESULT) { + delete entry->component; // NOLINT(cppcoreguidelines-owning-memory) + } + delete entry; // NOLINT(cppcoreguidelines-owning-memory) + } this->nextion_queue_.clear(); + for (auto *entry : this->waveform_queue_) { + delete entry; // NOLINT(cppcoreguidelines-owning-memory) + } this->waveform_queue_.clear(); } From cc8889628010840dcbc9167e07adf6ec342e00b9 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Wed, 1 Apr 2026 17:04:22 +0200 Subject: [PATCH 146/160] [debug] add peripherals status (#12053) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/debug/debug_zephyr.cpp | 47 ++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index bf87b7ae3d..d1580dae80 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -91,6 +91,49 @@ void DebugComponent::log_partition_info_() { flash_area_foreach(fa_cb, nullptr); } +#ifdef ESPHOME_LOG_HAS_VERBOSE +// Check if an nRF peripheral's ENABLE register indicates it is enabled. +// periph: peripheral register prefix (e.g. USBD, UARTE, SPI) +// reg: register block pointer (e.g. NRF_USBD, NRF_UARTE0) +#define NRF_PERIPH_ENABLED(periph, reg) \ + YESNO(((reg)->ENABLE & periph##_ENABLE_ENABLE_Msk) == (periph##_ENABLE_ENABLE_Enabled << periph##_ENABLE_ENABLE_Pos)) + +static void log_peripherals_info() { + // most peripherals are enabled only when in use so ESP_LOGV is enough + ESP_LOGV(TAG, "Peripherals status:"); + ESP_LOGV(TAG, " USBD: %-3s| UARTE0: %-3s| UARTE1: %-3s| UART0: %-3s", // + NRF_PERIPH_ENABLED(USBD, NRF_USBD), NRF_PERIPH_ENABLED(UARTE, NRF_UARTE0), + NRF_PERIPH_ENABLED(UARTE, NRF_UARTE1), NRF_PERIPH_ENABLED(UART, NRF_UART0)); + ESP_LOGV(TAG, " TWIS0: %-3s| TWIS1: %-3s| TWIM0: %-3s| TWIM1: %-3s", // + NRF_PERIPH_ENABLED(TWIS, NRF_TWIS0), NRF_PERIPH_ENABLED(TWIS, NRF_TWIS1), + NRF_PERIPH_ENABLED(TWIM, NRF_TWIM0), NRF_PERIPH_ENABLED(TWIM, NRF_TWIM1)); + ESP_LOGV(TAG, " TWI0: %-3s| TWI1: %-3s| COMP: %-3s| CCM: %-3s", // + NRF_PERIPH_ENABLED(TWI, NRF_TWI0), NRF_PERIPH_ENABLED(TWI, NRF_TWI1), NRF_PERIPH_ENABLED(COMP, NRF_COMP), + NRF_PERIPH_ENABLED(CCM, NRF_CCM)); + ESP_LOGV(TAG, " PDM: %-3s| SPIS0: %-3s| SPIS1: %-3s| SPIS2: %-3s", // + NRF_PERIPH_ENABLED(PDM, NRF_PDM), NRF_PERIPH_ENABLED(SPIS, NRF_SPIS0), NRF_PERIPH_ENABLED(SPIS, NRF_SPIS1), + NRF_PERIPH_ENABLED(SPIS, NRF_SPIS2)); + ESP_LOGV(TAG, " SPIM0: %-3s| SPIM1: %-3s| SPIM2: %-3s| SPIM3: %-3s", // + NRF_PERIPH_ENABLED(SPIM, NRF_SPIM0), NRF_PERIPH_ENABLED(SPIM, NRF_SPIM1), + NRF_PERIPH_ENABLED(SPIM, NRF_SPIM2), NRF_PERIPH_ENABLED(SPIM, NRF_SPIM3)); + ESP_LOGV(TAG, " SPI0: %-3s| SPI1: %-3s| SPI2: %-3s| SAADC: %-3s", // + NRF_PERIPH_ENABLED(SPI, NRF_SPI0), NRF_PERIPH_ENABLED(SPI, NRF_SPI1), NRF_PERIPH_ENABLED(SPI, NRF_SPI2), + NRF_PERIPH_ENABLED(SAADC, NRF_SAADC)); + ESP_LOGV(TAG, " QSPI: %-3s| QDEC: %-3s| LPCOMP: %-3s| I2S: %-3s", // + NRF_PERIPH_ENABLED(QSPI, NRF_QSPI), NRF_PERIPH_ENABLED(QDEC, NRF_QDEC), + NRF_PERIPH_ENABLED(LPCOMP, NRF_LPCOMP), NRF_PERIPH_ENABLED(I2S, NRF_I2S)); + ESP_LOGV(TAG, " PWM0: %-3s| PWM1: %-3s| PWM2: %-3s| PWM3: %-3s", // + NRF_PERIPH_ENABLED(PWM, NRF_PWM0), NRF_PERIPH_ENABLED(PWM, NRF_PWM1), NRF_PERIPH_ENABLED(PWM, NRF_PWM2), + NRF_PERIPH_ENABLED(PWM, NRF_PWM3)); + ESP_LOGV(TAG, " AAR: %-3s| QSPI deep power-down:%-3s| CRYPTOCELL: %-3s", NRF_PERIPH_ENABLED(AAR, NRF_AAR), + YESNO((NRF_QSPI->IFCONFIG0 & QSPI_IFCONFIG0_DPMENABLE_Msk) == + (QSPI_IFCONFIG0_DPMENABLE_Enable << QSPI_IFCONFIG0_DPMENABLE_Pos)), + YESNO((NRF_CRYPTOCELL->ENABLE & CRYPTOCELL_ENABLE_ENABLE_Msk) == + (CRYPTOCELL_ENABLE_ENABLE_Enabled << CRYPTOCELL_ENABLE_ENABLE_Pos))); +} +#undef NRF_PERIPH_ENABLED +#endif + static const char *regout0_to_str(uint32_t value) { switch (value) { case (UICR_REGOUT0_VOUT_DEFAULT): @@ -354,7 +397,9 @@ size_t DebugComponent::get_device_info_(std::span }; ESP_LOGD(TAG, " NRFFW %s", uicr(NRF_UICR->NRFFW, 13).c_str()); ESP_LOGD(TAG, " NRFHW %s", uicr(NRF_UICR->NRFHW, 12).c_str()); - +#ifdef ESPHOME_LOG_HAS_VERBOSE + log_peripherals_info(); +#endif return pos; } From f33fd047ee5589fa7d21445ed42bb541fe06e92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Pereira?= Date: Wed, 1 Apr 2026 17:09:22 +0100 Subject: [PATCH 147/160] [hdc2080] Add support for HDC2080 sensor (#9331) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: Big Mike Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/hdc2080/__init__.py | 1 + esphome/components/hdc2080/hdc2080.cpp | 71 +++++++++++++++++++ esphome/components/hdc2080/hdc2080.h | 24 +++++++ esphome/components/hdc2080/sensor.py | 57 +++++++++++++++ tests/components/hdc2080/common.yaml | 7 ++ tests/components/hdc2080/test.esp32-idf.yaml | 4 ++ .../components/hdc2080/test.esp8266-ard.yaml | 4 ++ tests/components/hdc2080/test.rp2040-ard.yaml | 4 ++ 9 files changed, 173 insertions(+) create mode 100644 esphome/components/hdc2080/__init__.py create mode 100644 esphome/components/hdc2080/hdc2080.cpp create mode 100644 esphome/components/hdc2080/hdc2080.h create mode 100644 esphome/components/hdc2080/sensor.py create mode 100644 tests/components/hdc2080/common.yaml create mode 100644 tests/components/hdc2080/test.esp32-idf.yaml create mode 100644 tests/components/hdc2080/test.esp8266-ard.yaml create mode 100644 tests/components/hdc2080/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 8d297d7b07..03f41618af 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -217,6 +217,7 @@ esphome/components/hbridge/light/* @DotNetDann esphome/components/hbridge/switch/* @dwmw2 esphome/components/hc8/* @omartijn esphome/components/hdc2010/* @optimusprimespace @ssieb +esphome/components/hdc2080/* @G-Pereira @jesserockz esphome/components/hdc302x/* @joshuasing esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch diff --git a/esphome/components/hdc2080/__init__.py b/esphome/components/hdc2080/__init__.py new file mode 100644 index 0000000000..341ea61048 --- /dev/null +++ b/esphome/components/hdc2080/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@G-Pereira", "@jesserockz"] diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp new file mode 100644 index 0000000000..dcb207e099 --- /dev/null +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -0,0 +1,71 @@ +#include "hdc2080.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::hdc2080 { + +static const char *const TAG = "hdc2080"; + +// Register map (Table 8-6) +static constexpr uint8_t REG_TEMPERATURE_LOW = 0x00; // Temperature [7:0] +static constexpr uint8_t REG_TEMPERATURE_HIGH = 0x01; // Temperature [15:8] +static constexpr uint8_t REG_HUMIDITY_LOW = 0x02; // Humidity [7:0] +static constexpr uint8_t REG_HUMIDITY_HIGH = 0x03; // Humidity [15:8] +static constexpr uint8_t REG_RESET_DRDY_INT_CONF = 0x0E; // Soft Reset and Interrupt Configuration +static constexpr uint8_t REG_MEASUREMENT_CONFIGURATION = 0x0F; + +// Measurement register (0x0F) bit fields +static constexpr uint8_t MEAS_TRIG = 0x01; // Bit 0: start measurement +static constexpr uint8_t MEAS_CONF_TEMP = 0x02; // Bits 2:1 = 01: temperature only +static constexpr uint8_t MEAS_CONF_HUM = 0x04; // Bits 2:1 = 10: humidity only + +void HDC2080Component::setup() { + const uint8_t data = 0x00; // automatic measurement mode disabled, heater off + if (this->write_register(REG_RESET_DRDY_INT_CONF, &data, 1) != i2c::ERROR_OK) { + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + return; + } +} + +void HDC2080Component::dump_config() { + ESP_LOGCONFIG(TAG, "HDC2080:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void HDC2080Component::update() { + uint8_t data = MEAS_TRIG; // 14-bit resolution, measure both, start + if (this->temperature_sensor_ != nullptr && this->humidity_sensor_ == nullptr) { + data = MEAS_TRIG | MEAS_CONF_TEMP; + } else if (this->temperature_sensor_ == nullptr && this->humidity_sensor_ != nullptr) { + data = MEAS_TRIG | MEAS_CONF_HUM; + } + if (this->write_register(REG_MEASUREMENT_CONFIGURATION, &data, 1) != i2c::ERROR_OK) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return; + } + // wait for conversion to complete 2ms should be enough, more is fine + this->set_timeout(5, [this]() { + uint8_t raw_data[4]; + if (this->read_register(REG_TEMPERATURE_LOW, raw_data, 4) != i2c::ERROR_OK) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return; + } + this->status_clear_warning(); + if (this->temperature_sensor_ != nullptr) { + float temp = encode_uint16(raw_data[1], raw_data[0]) * (165.0f / 65536.0f) - 40.5f; + this->temperature_sensor_->publish_state(temp); + } + if (this->humidity_sensor_ != nullptr) { + float humidity = encode_uint16(raw_data[3], raw_data[2]) * (100.0f / 65536.0f); + this->humidity_sensor_->publish_state(humidity); + } + }); +} + +} // namespace esphome::hdc2080 diff --git a/esphome/components/hdc2080/hdc2080.h b/esphome/components/hdc2080/hdc2080.h new file mode 100644 index 0000000000..daa10d371d --- /dev/null +++ b/esphome/components/hdc2080/hdc2080.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::hdc2080 { + +class HDC2080Component : public PollingComponent, public i2c::I2CDevice { + public: + void set_temperature(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } + void set_humidity(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } + + /// Setup the sensor and check for connection. + void setup() override; + void dump_config() override; + void update() override; + + protected: + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; +}; + +} // namespace esphome::hdc2080 diff --git a/esphome/components/hdc2080/sensor.py b/esphome/components/hdc2080/sensor.py new file mode 100644 index 0000000000..777fc51cba --- /dev/null +++ b/esphome/components/hdc2080/sensor.py @@ -0,0 +1,57 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_HUMIDITY, + CONF_ID, + CONF_TEMPERATURE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PERCENT, +) + +DEPENDENCIES = ["i2c"] + +hdc2080_ns = cg.esphome_ns.namespace("hdc2080") +HDC2080Component = hdc2080_ns.class_( + "HDC2080Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HDC2080Component), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x40)) + .add_extra(cv.has_at_least_one_key(CONF_TEMPERATURE, CONF_HUMIDITY)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(var.set_temperature(sens)) + + if humidity_config := config.get(CONF_HUMIDITY): + sens = await sensor.new_sensor(humidity_config) + cg.add(var.set_humidity(sens)) diff --git a/tests/components/hdc2080/common.yaml b/tests/components/hdc2080/common.yaml new file mode 100644 index 0000000000..cb14cb183b --- /dev/null +++ b/tests/components/hdc2080/common.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: hdc2080 + temperature: + name: Temperature + humidity: + name: Humidity + update_interval: 15s diff --git a/tests/components/hdc2080/test.esp32-idf.yaml b/tests/components/hdc2080/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/hdc2080/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/hdc2080/test.esp8266-ard.yaml b/tests/components/hdc2080/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/hdc2080/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/hdc2080/test.rp2040-ard.yaml b/tests/components/hdc2080/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/hdc2080/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From ea609d3552fe0685a68a97362abc5658f203837b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 07:09:04 -1000 Subject: [PATCH 148/160] [runtime_stats] Store stats inline on Component to eliminate std::map lookup (#15345) --- .../runtime_stats/runtime_stats.cpp | 76 +++++++++---------- .../components/runtime_stats/runtime_stats.h | 75 +----------------- esphome/core/application.h | 9 +++ esphome/core/component.cpp | 12 +-- esphome/core/component.h | 42 ++++++++++ esphome/core/defines.h | 1 + 6 files changed, 92 insertions(+), 123 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index cb28acc96c..06714b5a44 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -2,6 +2,7 @@ #ifdef USE_RUNTIME_STATS +#include "esphome/core/application.h" #include "esphome/core/component.h" #include @@ -13,20 +14,16 @@ RuntimeStatsCollector::RuntimeStatsCollector() : log_interval_(60000), next_log_ global_runtime_stats = this; } -void RuntimeStatsCollector::record_component_time(Component *component, uint32_t duration_us) { - if (component == nullptr) - return; - - // Record stats using component pointer as key - this->component_stats_[component].record_time(duration_us); -} - void RuntimeStatsCollector::log_stats_() { - // First pass: count active components + auto &components = App.components_; + + // Single pass: collect active components into stack buffer + SmallBufferWithHeapFallback<256, Component *> buffer(components.size()); + Component **sorted = buffer.get(); size_t count = 0; - for (const auto &it : this->component_stats_) { - if (it.second.get_period_count() > 0) { - count++; + for (auto *component : components) { + if (component->runtime_stats_.period_count > 0) { + sorted[count++] = component; } } @@ -39,61 +36,58 @@ void RuntimeStatsCollector::log_stats_() { return; } - // Stack buffer sized to actual active count (up to 256 components), heap fallback for larger - SmallBufferWithHeapFallback<256, Component *> buffer(count); - Component **sorted = buffer.get(); - - // Second pass: fill buffer with active components - size_t idx = 0; - for (const auto &it : this->component_stats_) { - if (it.second.get_period_count() > 0) { - sorted[idx++] = it.first; - } - } - // Sort by period runtime (descending) - std::sort(sorted, sorted + count, [this](Component *a, Component *b) { - return this->component_stats_[a].get_period_time_us() > this->component_stats_[b].get_period_time_us(); - }); + std::sort(sorted, sorted + count, compare_period_time); // Log top components by period runtime for (size_t i = 0; i < count; i++) { - const auto &stats = this->component_stats_[sorted[i]]; + const auto &stats = sorted[i]->runtime_stats_; ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.get_period_count(), - stats.get_period_avg_time_us() / 1000.0f, stats.get_period_max_time_us() / 1000.0f, - stats.get_period_time_us() / 1000.0f); + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, + stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, + stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); } // Log total stats since boot (only for active components - idle ones haven't changed) ESP_LOGI(TAG, " Total stats (since boot): %zu active components", count); // Re-sort by total runtime for all-time stats - std::sort(sorted, sorted + count, [this](Component *a, Component *b) { - return this->component_stats_[a].get_total_time_us() > this->component_stats_[b].get_total_time_us(); - }); + std::sort(sorted, sorted + count, compare_total_time); for (size_t i = 0; i < count; i++) { - const auto &stats = this->component_stats_[sorted[i]]; + const auto &stats = sorted[i]->runtime_stats_; ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.get_total_count(), - stats.get_total_avg_time_us() / 1000.0f, stats.get_total_max_time_us() / 1000.0f, - stats.get_total_time_us() / 1000.0); + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, + stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, + stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); } + + // Reset period stats + for (auto *component : components) { + component->runtime_stats_.reset_period(); + } +} + +bool RuntimeStatsCollector::compare_period_time(Component *a, Component *b) { + return a->runtime_stats_.period_time_us > b->runtime_stats_.period_time_us; +} + +bool RuntimeStatsCollector::compare_total_time(Component *a, Component *b) { + return a->runtime_stats_.total_time_us > b->runtime_stats_.total_time_us; } void RuntimeStatsCollector::process_pending_stats(uint32_t current_time) { if ((int32_t) (current_time - this->next_log_time_) >= 0) { this->log_stats_(); - this->reset_stats_(); this->next_log_time_ = current_time + this->log_interval_; } } } // namespace runtime_stats -runtime_stats::RuntimeStatsCollector *global_runtime_stats = - nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +runtime_stats::RuntimeStatsCollector + *global_runtime_stats = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + nullptr; } // namespace esphome diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 303d895985..3c2c9f78ad 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -4,11 +4,8 @@ #ifdef USE_RUNTIME_STATS -#include #include -#include #include "esphome/core/hal.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -19,64 +16,6 @@ namespace runtime_stats { static const char *const TAG = "runtime_stats"; -class ComponentRuntimeStats { - public: - ComponentRuntimeStats() - : period_count_(0), - period_time_us_(0), - period_max_time_us_(0), - total_count_(0), - total_time_us_(0), - total_max_time_us_(0) {} - - void record_time(uint32_t duration_us) { - // Update period counters - this->period_count_++; - this->period_time_us_ += duration_us; - if (duration_us > this->period_max_time_us_) - this->period_max_time_us_ = duration_us; - - // Update total counters (uint64_t to avoid overflow — uint32_t would overflow after ~10 hours) - this->total_count_++; - this->total_time_us_ += duration_us; - if (duration_us > this->total_max_time_us_) - this->total_max_time_us_ = duration_us; - } - - void reset_period_stats() { - this->period_count_ = 0; - this->period_time_us_ = 0; - this->period_max_time_us_ = 0; - } - - // Period stats (reset each logging interval) - uint32_t get_period_count() const { return this->period_count_; } - uint32_t get_period_time_us() const { return this->period_time_us_; } - uint32_t get_period_max_time_us() const { return this->period_max_time_us_; } - float get_period_avg_time_us() const { - return this->period_count_ > 0 ? this->period_time_us_ / static_cast(this->period_count_) : 0.0f; - } - - // Total stats (persistent until reboot, uint64_t to avoid overflow) - uint32_t get_total_count() const { return this->total_count_; } - uint64_t get_total_time_us() const { return this->total_time_us_; } - uint32_t get_total_max_time_us() const { return this->total_max_time_us_; } - float get_total_avg_time_us() const { - return this->total_count_ > 0 ? this->total_time_us_ / static_cast(this->total_count_) : 0.0f; - } - - protected: - // Period stats (reset each logging interval) - uint32_t period_count_; - uint32_t period_time_us_; - uint32_t period_max_time_us_; - - // Total stats (persistent until reboot) - uint32_t total_count_; - uint64_t total_time_us_; - uint32_t total_max_time_us_; -}; - class RuntimeStatsCollector { public: RuntimeStatsCollector(); @@ -87,23 +26,15 @@ class RuntimeStatsCollector { } uint32_t get_log_interval() const { return this->log_interval_; } - void record_component_time(Component *component, uint32_t duration_us); - // Process any pending stats printing (should be called after component loop) void process_pending_stats(uint32_t current_time); protected: void log_stats_(); + // Static comparators — member functions have friend access, lambdas do not + static bool compare_period_time(Component *a, Component *b); + static bool compare_total_time(Component *a, Component *b); - void reset_stats_() { - for (auto &it : this->component_stats_) { - it.second.reset_period_stats(); - } - } - - // Map from component to its stats - // We use Component* as the key since each component is unique - std::map component_stats_; uint32_t log_interval_; uint32_t next_log_time_{0}; }; diff --git a/esphome/core/application.h b/esphome/core/application.h index 06ff30e81f..6cc61bc954 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -130,6 +130,12 @@ bool socket_ready_fd(int fd, bool loop_monitored); // NOLINT(readability-redund #endif } // namespace esphome::socket +#ifdef USE_RUNTIME_STATS +namespace esphome::runtime_stats { +class RuntimeStatsCollector; +} // namespace esphome::runtime_stats +#endif + // Forward declarations for friend access from codegen-generated setup() void setup(); // NOLINT(readability-redundant-declaration) - may be declared in Arduino.h void original_setup(); // NOLINT(readability-redundant-declaration) - used by cpp unit tests @@ -590,6 +596,9 @@ class Application { friend Component; #if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) friend bool socket::socket_ready_fd(int fd, bool loop_monitored); +#endif +#ifdef USE_RUNTIME_STATS + friend class runtime_stats::RuntimeStatsCollector; #endif friend void ::setup(); friend void ::original_setup(); diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 00a36fce3d..955596ce95 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -9,9 +9,6 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_RUNTIME_STATS -#include "esphome/components/runtime_stats/runtime_stats.h" -#endif namespace esphome { @@ -524,13 +521,8 @@ WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t block #ifdef USE_RUNTIME_STATS void WarnIfComponentBlockingGuard::record_runtime_stats_() { - // Use micros() for accurate sub-millisecond timing. millis() has insufficient - // resolution — most components complete in microseconds but millis() only has - // 1ms granularity, so results were essentially random noise. - if (global_runtime_stats != nullptr) { - uint32_t duration_us = micros() - this->started_us_; - global_runtime_stats->record_component_time(this->component_, duration_us); - } + uint32_t duration_us = micros() - this->started_us_; + this->component_->runtime_stats_.record_time(duration_us); } #endif diff --git a/esphome/core/component.h b/esphome/core/component.h index c390a205f0..c5a331ee29 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -20,6 +20,12 @@ namespace esphome { // Forward declaration for LogString struct LogString; +#ifdef USE_RUNTIME_STATS +namespace runtime_stats { +class RuntimeStatsCollector; +} // namespace runtime_stats +#endif + /** Default setup priorities for components of different types. * * Components should return one of these setup priorities in get_setup_priority. @@ -92,6 +98,37 @@ inline constexpr uint8_t WARN_IF_BLOCKING_OVER_CS = 5U; // 50ms in centiseconds /// Weak default returns "" so builds without codegen still link. const LogString *component_source_lookup(uint8_t index); +#ifdef USE_RUNTIME_STATS +/// Inline runtime statistics — eliminates std::map lookup on every loop iteration. +/// Only present when USE_RUNTIME_STATS is defined (profiling builds). +struct ComponentRuntimeStats { + // Period stats (reset each logging interval) + uint32_t period_count{0}; + uint32_t period_time_us{0}; + uint32_t period_max_time_us{0}; + // Total stats (persistent until reboot, uint64_t to avoid overflow) + uint32_t total_count{0}; + uint64_t total_time_us{0}; + uint32_t total_max_time_us{0}; + + void record_time(uint32_t duration_us) { + this->period_count++; + this->period_time_us += duration_us; + if (duration_us > this->period_max_time_us) + this->period_max_time_us = duration_us; + this->total_count++; + this->total_time_us += duration_us; + if (duration_us > this->total_max_time_us) + this->total_max_time_us = duration_us; + } + void reset_period() { + this->period_count = 0; + this->period_time_us = 0; + this->period_max_time_us = 0; + } +}; +#endif + class Component { public: /** Where the component's initialization should happen. @@ -529,6 +566,11 @@ class Component { /// Bits 6-7: Unused - reserved for future expansion uint8_t component_state_{0x00}; volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context +#ifdef USE_RUNTIME_STATS + friend class runtime_stats::RuntimeStatsCollector; + friend class WarnIfComponentBlockingGuard; + ComponentRuntimeStats runtime_stats_; +#endif }; /** This class simplifies creating components that periodically check a state. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7259167a52..23e65f55bc 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -180,6 +180,7 @@ #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG #define USE_RUNTIME_IMAGE_JPEG +#define USE_RUNTIME_STATS #define USE_OTA #define USE_OTA_PASSWORD #define USE_OTA_STATE_LISTENER From 2e3ea2152d103a21b0cfbe5018bd3a47aabe285a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:13:23 -0400 Subject: [PATCH 149/160] [esp32_camera] Bump esp32-camera to v2.1.6 (#15349) --- esphome/components/camera_encoder/__init__.py | 2 +- esphome/components/esp32_camera/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index a0c59a517a..3bbeae7835 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -50,7 +50,7 @@ async def to_code(config: ConfigType) -> None: buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID]) cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: - add_idf_component(name="espressif/esp32-camera", ref="2.1.5") + add_idf_component(name="espressif/esp32-camera", ref="2.1.6") cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index afab849a7c..66af321e4e 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -400,7 +400,7 @@ async def to_code(config): if config[CONF_JPEG_QUALITY] != 0 and config[CONF_PIXEL_FORMAT] != "JPEG": cg.add_define("USE_ESP32_CAMERA_JPEG_CONVERSION") - add_idf_component(name="espressif/esp32-camera", ref="2.1.5") + add_idf_component(name="espressif/esp32-camera", ref="2.1.6") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c44853969e..462af5d1e7 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.1.5 + version: 2.1.6 espressif/mdns: version: 1.10.0 espressif/esp_wifi_remote: From bdce47e764497cd75051f885ffd2916fdf980459 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:39:51 +1000 Subject: [PATCH 150/160] [lvgl] Fixes #4 (#15334) --- esphome/components/lvgl/__init__.py | 22 +++++++++++++++++----- esphome/components/lvgl/lvgl_esphome.h | 8 +++++--- esphome/components/lvgl/schemas.py | 1 + tests/components/lvgl/lvgl-package.yaml | 8 ++++++++ 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 736fba759f..3b4f150699 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -380,8 +380,10 @@ async def to_code(configs): # This must be done after all widgets are created for comp in helpers.lvgl_components_required: cg.add_define(f"USE_LVGL_{comp.upper()}") - # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending - lv_image_formats = {"RGB565", "ARGB8888"} + for use in helpers.lv_uses: + df.add_define(f"LV_USE_{use.upper()}") + cg.add_define(f"USE_LVGL_{use.upper()}") + if { "transform_rotation", "transform_scale", @@ -389,9 +391,18 @@ async def to_code(configs): "transform_scale_y", } & styles_used: df.add_define("LV_COLOR_SCREEN_TRANSP", "1") - for use in helpers.lv_uses: - df.add_define(f"LV_USE_{use.upper()}") - cg.add_define(f"USE_LVGL_{use.upper()}") + + # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending + lv_image_formats = {"RGB565", "ARGB8888"} + if { + "drop_shadow_color", + "drop_shadow_offset_x", + "drop_shadow_offset_y", + "drop_shadow_opa", + "drop_shadow_quality", + "drop_shadow_radius", + } & styles_used: + lv_image_formats.add("A8") for image_id in lv_images_used: await cg.get_variable(image_id) @@ -410,6 +421,7 @@ async def to_code(configs): lv_image_formats.add("RGB888") for fmt in lv_image_formats: df.add_define(f"LV_DRAW_SW_SUPPORT_{fmt}", "1") + lv_conf_h_file = CORE.relative_src_path(LV_CONF_FILENAME) write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()) cg.add_build_flag("-DLV_CONF_H=1") diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 21d1e0d417..8d139b23cb 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -74,11 +74,13 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { #if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) // Shortcut / overload, so that the source of an image can easily be updated // from within a lambda. -inline void lv_image_set_src(lv_obj_t *obj, esphome::image::Image *image) { - lv_image_set_src(obj, image->get_lv_image_dsc()); +inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { lv_image_set_src(obj, image->get_lv_image_dsc()); } + +inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { + lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); } -inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, esphome::image::Image *image, lv_style_selector_t selector) { +inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); } #endif // USE_LVGL_IMAGE diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 9c9504f05f..4f1473b652 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -168,6 +168,7 @@ BASE_PROPS = { "bg_main_opa": lvalid.opacity, "bg_main_stop": lvalid.stop_value, "bg_opa": lvalid.opacity, + "bitmap_mask_src": lvalid.lv_image, "blend_mode": df.LvConstant( "LV_BLEND_MODE_", "NORMAL", diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index b8c9a1809e..abc66ef587 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -511,6 +511,7 @@ lvgl: image: src: cat_image align: top_left + bitmap_mask_src: alert on_click: - lvgl.widget.focus: spin_up - lvgl.widget.focus: next @@ -1189,6 +1190,13 @@ image: type: BINARY transparency: chroma_key + - id: alert + file: $component_dir/logo-text.svg + type: grayscale + resize: 100x100 + invert_alpha: true + transparency: alpha_channel + color: - id: light_blue hex: "3340FF" From 5cdbbd48873b0cfed261b2c19081fd42e99967a0 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Wed, 1 Apr 2026 23:48:47 +0200 Subject: [PATCH 151/160] [mitsubishi_cn105] Add climate component for Mitsubishi A/C units with CN105 connector (Part 1) (#15315) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/mitsubishi_cn105/__init__.py | 0 .../components/mitsubishi_cn105/climate.py | 41 +++++++++++++++++++ .../mitsubishi_cn105/mitsubishi_cn105.cpp | 7 ++++ .../mitsubishi_cn105/mitsubishi_cn105.h | 19 +++++++++ .../mitsubishi_cn105_climate.cpp | 28 +++++++++++++ .../mitsubishi_cn105_climate.h | 27 ++++++++++++ tests/components/mitsubishi_cn105/common.yaml | 4 ++ .../mitsubishi_cn105/test.esp32-idf.yaml | 4 ++ .../mitsubishi_cn105/test.esp8266-ard.yaml | 4 ++ .../mitsubishi_cn105/test.rp2040-ard.yaml | 4 ++ 11 files changed, 139 insertions(+) create mode 100644 esphome/components/mitsubishi_cn105/__init__.py create mode 100644 esphome/components/mitsubishi_cn105/climate.py create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105.h create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h create mode 100644 tests/components/mitsubishi_cn105/common.yaml create mode 100644 tests/components/mitsubishi_cn105/test.esp32-idf.yaml create mode 100644 tests/components/mitsubishi_cn105/test.esp8266-ard.yaml create mode 100644 tests/components/mitsubishi_cn105/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 03f41618af..fffe5ce91c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -331,6 +331,7 @@ esphome/components/mipi_dsi/* @clydebarrow esphome/components/mipi_rgb/* @clydebarrow esphome/components/mipi_spi/* @clydebarrow esphome/components/mitsubishi/* @RubyBailey +esphome/components/mitsubishi_cn105/* @crnjan esphome/components/mixer/speaker/* @kahrendt esphome/components/mlx90393/* @functionpointer esphome/components/mlx90614/* @jesserockz diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py new file mode 100644 index 0000000000..5ea72d4cd2 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import climate, uart +import esphome.config_validation as cv +from esphome.const import CONF_UPDATE_INTERVAL +from esphome.types import ConfigType + +DEPENDENCIES = ["uart"] +AUTO_LOAD = ["climate"] +CODEOWNERS = ["@crnjan"] + +mitsubishi_ns = cg.esphome_ns.namespace("mitsubishi_cn105") + +MitsubishiCN105Climate = mitsubishi_ns.class_( + "MitsubishiCN105Climate", + climate.Climate, + cg.Component, + uart.UARTDevice, +) + +CONFIG_SCHEMA = ( + climate.climate_schema(MitsubishiCN105Climate) + .extend(uart.UART_DEVICE_SCHEMA) + .extend({cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval}) +) + +FINAL_VALIDATE_SCHEMA = cv.All( + uart.final_validate_device_schema( + "mitsubishi_cn105", + require_rx=True, + require_tx=True, + data_bits=8, + parity="EVEN", + stop_bits=1, + ) +) + + +async def to_code(config: ConfigType) -> None: + var = await climate.new_climate(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp new file mode 100644 index 0000000000..35ab405b2d --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -0,0 +1,7 @@ +#include "mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105 { + +static const char *const TAG = "mitsubishi_cn105.driver"; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h new file mode 100644 index 0000000000..6018dddbff --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/uart/uart.h" + +namespace esphome::mitsubishi_cn105 { + +class MitsubishiCN105 { + public: + explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {} + + uint32_t get_update_interval() const { return this->update_interval_ms_; } + void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; } + + protected: + uart::UARTDevice &device_; + uint32_t update_interval_ms_{1000}; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp new file mode 100644 index 0000000000..6d50296c8f --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -0,0 +1,28 @@ +#include "mitsubishi_cn105_climate.h" +#include "esphome/core/log.h" + +namespace esphome::mitsubishi_cn105 { + +static const char *const TAG = "mitsubishi_cn105.climate"; + +void MitsubishiCN105Climate::dump_config() { + LOG_CLIMATE("", "Mitsubishi CN105 Climate", this); + ESP_LOGCONFIG(TAG, + " Update interval: %" PRIu32 " ms\n" + " UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u", + this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(), + LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); +} + +void MitsubishiCN105Climate::setup() {} + +void MitsubishiCN105Climate::loop() {} + +climate::ClimateTraits MitsubishiCN105Climate::traits() { + climate::ClimateTraits traits; + return traits; +} + +void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {} + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h new file mode 100644 index 0000000000..08b482025f --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/climate/climate.h" +#include "esphome/components/uart/uart.h" +#include "mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105 { + +class MitsubishiCN105Climate : public climate::Climate, public Component, public uart::UARTDevice { + public: + explicit MitsubishiCN105Climate() : hp_(*this) {} + + void setup() override; + void loop() override; + void dump_config() override; + + climate::ClimateTraits traits() override; + void control(const climate::ClimateCall &call) override; + + void set_update_interval(uint32_t ms) { hp_.set_update_interval(ms); } + + protected: + MitsubishiCN105 hp_; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml new file mode 100644 index 0000000000..e885ceef81 --- /dev/null +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -0,0 +1,4 @@ +climate: + - platform: mitsubishi_cn105 + name: "AC Test" + uart_id: uart_bus diff --git a/tests/components/mitsubishi_cn105/test.esp32-idf.yaml b/tests/components/mitsubishi_cn105/test.esp32-idf.yaml new file mode 100644 index 0000000000..ac63cf987f --- /dev/null +++ b/tests/components/mitsubishi_cn105/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/mitsubishi_cn105/test.esp8266-ard.yaml b/tests/components/mitsubishi_cn105/test.esp8266-ard.yaml new file mode 100644 index 0000000000..9f2f350b46 --- /dev/null +++ b/tests/components/mitsubishi_cn105/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_9600_even/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/mitsubishi_cn105/test.rp2040-ard.yaml b/tests/components/mitsubishi_cn105/test.rp2040-ard.yaml new file mode 100644 index 0000000000..4363d6eee8 --- /dev/null +++ b/tests/components/mitsubishi_cn105/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_9600_even/rp2040-ard.yaml + +<<: !include common.yaml From b5c4449a161c2fc83b4e7b5150b50dcc6ef1ea71 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:11:44 -1000 Subject: [PATCH 152/160] Bump pillow from 12.1.1 to 12.2.0 (#15361) 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 8ad5528c95..dd20600097 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -pillow==12.1.1 +pillow==12.2.0 resvg-py==0.2.6 freetype-py==2.5.1 jinja2==3.1.6 From eefbb42be477d1ed2e80c251859a4588df61c47b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:16:56 +1000 Subject: [PATCH 153/160] [lvgl] Add missing event names (#15362) --- esphome/components/lvgl/defines.py | 75 +++++++++++++--- tests/components/lvgl/lvgl-package.yaml | 108 ++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 11 deletions(-) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index dd51a2f519..500ccb608a 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -251,22 +251,75 @@ LV_FONTS = list(f"montserrat_{s}" for s in range(8, 50, 2)) + [ ] LV_EVENT_MAP = { - "PRESS": "PRESSED", - "SHORT_CLICK": "SHORT_CLICKED", + "ALL_EVENTS": "ALL", + "CANCEL": "CANCEL", + "CHANGE": "VALUE_CHANGED", + "CHILD_CHANGE": "CHILD_CHANGED", + "CHILD_CREATE": "CHILD_CREATED", + "CHILD_DELETE": "CHILD_DELETED", + "CLICK": "CLICKED", + "COLOR_FORMAT_CHANGE": "COLOR_FORMAT_CHANGED", + "COVER_CHECK": "COVER_CHECK", + "CREATE": "CREATE", + "DEFOCUS": "DEFOCUSED", + "DELETE": "DELETE", + "DOUBLE_CLICK": "DOUBLE_CLICKED", + "DRAW_MAIN": "DRAW_MAIN", + "DRAW_MAIN_BEGIN": "DRAW_MAIN_BEGIN", + "DRAW_MAIN_END": "DRAW_MAIN_END", + "DRAW_POST": "DRAW_POST", + "DRAW_POST_BEGIN": "DRAW_POST_BEGIN", + "DRAW_POST_END": "DRAW_POST_END", + "DRAW_TASK_ADD": "DRAW_TASK_ADDED", + "FLUSH_FINISH": "FLUSH_FINISH", + "FLUSH_START": "FLUSH_START", + "FLUSH_WAIT_FINISH": "FLUSH_WAIT_FINISH", + "FLUSH_WAIT_START": "FLUSH_WAIT_START", + "FOCUS": "FOCUSED", + "GESTURE": "GESTURE", + "GET_SELF_SIZE": "GET_SELF_SIZE", + "HIT_TEST": "HIT_TEST", + "HOVER_LEAVE": "HOVER_LEAVE", + "HOVER_OVER": "HOVER_OVER", + "INDEV_RESET": "INDEV_RESET", + "INSERT": "INSERT", + "INVALIDATE_AREA": "INVALIDATE_AREA", + "KEY": "KEY", + "LAYOUT_CHANGE": "LAYOUT_CHANGED", + "LEAVE": "LEAVE", "LONG_PRESS": "LONG_PRESSED", "LONG_PRESS_REPEAT": "LONG_PRESSED_REPEAT", - "CLICK": "CLICKED", + "PRESS": "PRESSED", + "PRESS_LOST": "PRESS_LOST", + "PRESSING": "PRESSING", + "READY": "READY", + "REFRESH": "REFRESH", + "REFR_EXT_DRAW_SIZE": "REFR_EXT_DRAW_SIZE", + "REFR_READY": "REFR_READY", + "REFR_REQUEST": "REFR_REQUEST", + "REFR_START": "REFR_START", "RELEASE": "RELEASED", + "RENDER_READY": "RENDER_READY", + "RENDER_START": "RENDER_START", + "RESOLUTION_CHANGE": "RESOLUTION_CHANGED", + "ROTARY": "ROTARY", + "SCREEN_LOAD": "SCREEN_LOADED", + "SCREEN_LOAD_START": "SCREEN_LOAD_START", + "SCREEN_UNLOAD": "SCREEN_UNLOADED", + "SCREEN_UNLOAD_START": "SCREEN_UNLOAD_START", + "SCROLL": "SCROLL", "SCROLL_BEGIN": "SCROLL_BEGIN", "SCROLL_END": "SCROLL_END", - "SCROLL": "SCROLL", - "FOCUS": "FOCUSED", - "DEFOCUS": "DEFOCUSED", - "READY": "READY", - "CANCEL": "CANCEL", - "ALL_EVENTS": "ALL", - "CHANGE": "VALUE_CHANGED", - "GESTURE": "GESTURE", + "SCROLL_THROW_BEGIN": "SCROLL_THROW_BEGIN", + "SHORT_CLICK": "SHORT_CLICKED", + "SINGLE_CLICK": "SINGLE_CLICKED", + "SIZE_CHANGE": "SIZE_CHANGED", + "STATE_CHANGE": "STATE_CHANGED", + "STYLE_CHANGE": "STYLE_CHANGED", + "TRIPLE_CLICK": "TRIPLE_CLICKED", + "UPDATE_LAYOUT_COMPLETE": "UPDATE_LAYOUT_COMPLETED", + "VSYNC": "VSYNC", + "VSYNC_REQUEST": "VSYNC_REQUEST", } LV_EVENT_TRIGGERS = tuple(f"on_{x.lower()}" for x in LV_EVENT_MAP) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index abc66ef587..3a6af93b64 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -590,6 +590,114 @@ lvgl: logger.log: Button clicked on_long_press_repeat: logger.log: Button clicked + on_pressing: + logger.log: Button pressing + on_press_lost: + logger.log: Button press lost + on_single_click: + logger.log: Button single clicked + on_double_click: + logger.log: Button double clicked + on_triple_click: + logger.log: Button triple clicked + on_scroll_throw_begin: + logger.log: Scroll throw begin + on_gesture: + logger.log: Gesture detected + on_key: + logger.log: Key event + on_rotary: + logger.log: Rotary event + on_leave: + logger.log: Leave event + on_hit_test: + logger.log: Hit test + on_indev_reset: + logger.log: Indev reset + on_hover_over: + logger.log: Hover over + on_hover_leave: + logger.log: Hover leave + on_cover_check: + logger.log: Cover check + on_refr_ext_draw_size: + logger.log: Refr ext draw size + on_draw_main_begin: + logger.log: Draw main begin + on_draw_main: + logger.log: Draw main + on_draw_main_end: + logger.log: Draw main end + on_draw_post_begin: + logger.log: Draw post begin + on_draw_post: + logger.log: Draw post + on_draw_post_end: + logger.log: Draw post end + on_draw_task_add: + logger.log: Draw task add + on_insert: + logger.log: Insert event + on_refresh: + logger.log: Refresh event + on_state_change: + logger.log: State changed + on_create: + logger.log: Create event + on_delete: + logger.log: Delete event + on_child_change: + logger.log: Child changed + on_child_create: + logger.log: Child created + on_child_delete: + logger.log: Child deleted + on_screen_unload_start: + logger.log: Screen unload start + on_screen_load_start: + logger.log: Screen load start + on_screen_load: + logger.log: Screen loaded + on_screen_unload: + logger.log: Screen unloaded + on_size_change: + logger.log: Size changed + on_style_change: + logger.log: Style changed + on_layout_change: + logger.log: Layout changed + on_get_self_size: + logger.log: Get self size + on_invalidate_area: + logger.log: Invalidate area + on_resolution_change: + logger.log: Resolution changed + on_color_format_change: + logger.log: Color format changed + on_refr_request: + logger.log: Refresh request + on_refr_start: + logger.log: Refresh start + on_refr_ready: + logger.log: Refresh ready + on_render_start: + logger.log: Render start + on_render_ready: + logger.log: Render ready + on_flush_start: + logger.log: Flush start + on_flush_finish: + logger.log: Flush finish + on_flush_wait_start: + logger.log: Flush wait start + on_flush_wait_finish: + logger.log: Flush wait finish + on_update_layout_complete: + logger.log: Update layout complete + on_vsync: + logger.log: Vsync + on_vsync_request: + logger.log: Vsync request - led: id: lv_led color: 0x00FF00 From 27c662e73faf6583179084d6a179816751d8d170 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 16:11:50 -1000 Subject: [PATCH 154/160] [bluetooth_proxy] Replace loop() with set_interval for advertisement flushing (#15347) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 48 ++++++------------- .../bluetooth_proxy/bluetooth_proxy.h | 17 +++++-- 2 files changed, 27 insertions(+), 38 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 87206996b2..c69163b1f7 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -30,6 +30,19 @@ void BluetoothProxy::setup() { this->configured_scan_active_ = this->parent_->get_scan_active(); this->parent_->add_scanner_state_listener(this); + + this->set_interval(100, [this]() { + if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) { + this->flush_pending_advertisements_(); + return; + } + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->get_address() != 0 && !connection->disconnect_pending()) { + connection->disconnect(); + } + } + }); } void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { @@ -101,25 +114,15 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { - this->flush_pending_advertisements(); + this->flush_pending_advertisements_(); } } return true; } -void BluetoothProxy::flush_pending_advertisements() { - if (this->response_.advertisements_len == 0 || !api::global_api_server->is_connected() || - this->api_connection_ == nullptr) - return; - - // Send the message - this->api_connection_->send_message(this->response_); - +void BluetoothProxy::log_advertisement_flush_() { ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); - - // Reset the length for the next batch - this->response_.advertisements_len = 0; } void BluetoothProxy::dump_config() { @@ -130,27 +133,6 @@ void BluetoothProxy::dump_config() { YESNO(this->active_), this->connection_count_); } -void BluetoothProxy::loop() { - if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { - for (uint8_t i = 0; i < this->connection_count_; i++) { - auto *connection = this->connections_[i]; - if (connection->get_address() != 0 && !connection->disconnect_pending()) { - connection->disconnect(); - } - } - return; - } - - // Flush any pending BLE advertisements that have been accumulated but not yet sent - uint32_t now = App.get_loop_component_start_time(); - - // Flush accumulated advertisements every 100ms - if (now - this->last_advertisement_flush_time_ >= 100) { - this->flush_pending_advertisements(); - this->last_advertisement_flush_time_ = now; - } -} - esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index f1b723e719..6680ab0e84 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -65,8 +65,6 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; void dump_config() override; void setup() override; - void loop() override; - void flush_pending_advertisements(); esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; void register_connection(BluetoothConnection *connection) { @@ -150,6 +148,18 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, protected: void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); + /// Caller must ensure api_connection_ is non-null and API server is connected. + void flush_pending_advertisements_() { + if (this->response_.advertisements_len == 0) + return; + this->api_connection_->send_message(this->response_); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + this->log_advertisement_flush_(); +#endif + this->response_.advertisements_len = 0; + } + void log_advertisement_flush_(); + BluetoothConnection *get_connection_(uint64_t address, bool reserve); void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); @@ -166,9 +176,6 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; - // Group 3: 4-byte types - uint32_t last_advertisement_flush_time_{0}; - // Pre-allocated response message - always ready to send api::BluetoothConnectionsFreeResponse connections_free_response_; From bcc7b8f490cae830be31bb3e891c5a39a43fac37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 16:12:02 -1000 Subject: [PATCH 155/160] [api] Add send_sensor_state benchmarks (#15352) --- esphome/components/api/api_connection.h | 12 ++ .../benchmarks/components/api/bench_helpers.h | 67 ++++++ .../components/api/bench_plaintext_frame.cpp | 58 +----- .../api/bench_send_sensor_state.cpp | 191 ++++++++++++++++++ 4 files changed, 272 insertions(+), 56 deletions(-) create mode 100644 tests/benchmarks/components/api/bench_helpers.h create mode 100644 tests/benchmarks/components/api/bench_send_sensor_state.cpp diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3d8563b1ae..4ce1335650 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -44,10 +44,22 @@ static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= AP static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH"); +#ifdef USE_BENCHMARK +class APIConnection; +void bench_enable_immediate_send(APIConnection *conn); +void bench_clear_batch(APIConnection *conn); +void bench_process_batch(APIConnection *conn); +#endif + class APIConnection final : public APIServerConnectionBase { public: friend class APIServer; friend class ListEntitiesIterator; +#ifdef USE_BENCHMARK + friend void bench_enable_immediate_send(APIConnection *conn); + friend void bench_clear_batch(APIConnection *conn); + friend void bench_process_batch(APIConnection *conn); +#endif APIConnection(std::unique_ptr socket, APIServer *parent); ~APIConnection(); diff --git a/tests/benchmarks/components/api/bench_helpers.h b/tests/benchmarks/components/api/bench_helpers.h new file mode 100644 index 0000000000..73e51bce3d --- /dev/null +++ b/tests/benchmarks/components/api/bench_helpers.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include "esphome/components/socket/socket.h" + +namespace esphome::api::benchmarks { + +// Helper to drain accumulated data from the read side of a socket +// to prevent the write side from blocking. +inline void drain_socket(int fd) { + char buf[65536]; + while (::read(fd, buf, sizeof(buf)) > 0) { + } +} + +// Create a TCP loopback socket pair. Returns the write-side Socket +// (wrapped for ESPHome) and the raw read-side fd for draining. +// Both ends are non-blocking with 16MB buffers. +inline std::pair, int> create_tcp_loopback() { + // Create a TCP listener on loopback + int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0); + int opt = 1; + ::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // OS-assigned port + ::bind(listen_fd, reinterpret_cast(&addr), sizeof(addr)); + ::listen(listen_fd, 1); + + // Get the assigned port + socklen_t addr_len = sizeof(addr); + ::getsockname(listen_fd, reinterpret_cast(&addr), &addr_len); + + // Connect from client side + int write_fd = ::socket(AF_INET, SOCK_STREAM, 0); + ::connect(write_fd, reinterpret_cast(&addr), sizeof(addr)); + + // Accept on server side (this is our read fd) + int read_fd = ::accept(listen_fd, nullptr, nullptr); + ::close(listen_fd); + + // Make both ends non-blocking + int flags = ::fcntl(write_fd, F_GETFL, 0); + ::fcntl(write_fd, F_SETFL, flags | O_NONBLOCK); + flags = ::fcntl(read_fd, F_GETFL, 0); + ::fcntl(read_fd, F_SETFL, flags | O_NONBLOCK); + + // Use large socket buffers so benchmarks never hit WOULD_BLOCK + // during a single outer iteration (2000 × ~15B messages = ~30KB). + int bufsize = 16 * 1024 * 1024; + ::setsockopt(write_fd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); + ::setsockopt(read_fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); + + return {std::make_unique(write_fd), read_fd}; +} + +} // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/components/api/bench_plaintext_frame.cpp b/tests/benchmarks/components/api/bench_plaintext_frame.cpp index 79bffaf953..0caa50c748 100644 --- a/tests/benchmarks/components/api/bench_plaintext_frame.cpp +++ b/tests/benchmarks/components/api/bench_plaintext_frame.cpp @@ -2,12 +2,9 @@ #ifdef USE_API_PLAINTEXT #include -#include -#include -#include -#include #include +#include "bench_helpers.h" #include "esphome/components/api/api_frame_helper_plaintext.h" #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_buffer.h" @@ -16,57 +13,12 @@ namespace esphome::api::benchmarks { static constexpr int kInnerIterations = 2000; -// Helper to drain accumulated data from the read side of a socket -// to prevent the write side from blocking. -static void drain_socket(int fd) { - char buf[65536]; - while (::read(fd, buf, sizeof(buf)) > 0) { - } -} - // Helper to create a TCP loopback connection with an APIPlaintextFrameHelper // on the write end. Returns the helper and the read-side fd. -// Uses real TCP sockets so TCP_NODELAY succeeds during init(). static std::pair, int> create_plaintext_helper() { - // Create a TCP listener on loopback - int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0); - int opt = 1; - ::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); - - struct sockaddr_in addr {}; - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - addr.sin_port = 0; // OS-assigned port - ::bind(listen_fd, reinterpret_cast(&addr), sizeof(addr)); - ::listen(listen_fd, 1); - - // Get the assigned port - socklen_t addr_len = sizeof(addr); - ::getsockname(listen_fd, reinterpret_cast(&addr), &addr_len); - - // Connect from client side - int write_fd = ::socket(AF_INET, SOCK_STREAM, 0); - ::connect(write_fd, reinterpret_cast(&addr), sizeof(addr)); - - // Accept on server side (this is our read fd) - int read_fd = ::accept(listen_fd, nullptr, nullptr); - ::close(listen_fd); - - // Make both ends non-blocking - int flags = ::fcntl(write_fd, F_GETFL, 0); - ::fcntl(write_fd, F_SETFL, flags | O_NONBLOCK); - flags = ::fcntl(read_fd, F_GETFL, 0); - ::fcntl(read_fd, F_SETFL, flags | O_NONBLOCK); - - // Increase socket buffer sizes to reduce drain frequency - int bufsize = 1024 * 1024; - ::setsockopt(write_fd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); - ::setsockopt(read_fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); - - auto sock = std::make_unique(write_fd); + auto [sock, read_fd] = create_tcp_loopback(); auto helper = std::make_unique(std::move(sock)); helper->init(); - return {std::move(helper), read_fd}; } @@ -97,9 +49,6 @@ static void PlaintextFrame_WriteSensorState(benchmark::State &state) { msg.encode(writer); helper->write_protobuf_packet(SensorStateResponse::MESSAGE_TYPE, writer); - - if ((i & 0xFF) == 0) - drain_socket(read_fd); } drain_socket(read_fd); benchmark::DoNotOptimize(helper.get()); @@ -144,9 +93,6 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) { } helper->write_protobuf_messages(ProtoWriteBuffer(&buffer, 0), std::span(messages, 5)); - - if ((i & 0xFF) == 0) - drain_socket(read_fd); } drain_socket(read_fd); benchmark::DoNotOptimize(helper.get()); diff --git a/tests/benchmarks/components/api/bench_send_sensor_state.cpp b/tests/benchmarks/components/api/bench_send_sensor_state.cpp new file mode 100644 index 0000000000..815081374a --- /dev/null +++ b/tests/benchmarks/components/api/bench_send_sensor_state.cpp @@ -0,0 +1,191 @@ +#include "esphome/core/defines.h" +#if defined(USE_API_PLAINTEXT) && defined(USE_SENSOR) + +#include +#include + +#include "bench_helpers.h" +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_server.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::api { + +// Friend functions declared in APIConnection for benchmark access. +void bench_enable_immediate_send(APIConnection *conn) { conn->flags_.should_try_send_immediately = true; } +void bench_clear_batch(APIConnection *conn) { conn->clear_batch_(); } +void bench_process_batch(APIConnection *conn) { conn->process_batch_(); } + +} // namespace esphome::api + +namespace esphome::api::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// Helper to create a TCP loopback connection with an APIConnection. +// Returns the connection and the read-side fd for draining. +static std::pair, int> create_api_connection() { + auto [sock, read_fd] = create_tcp_loopback(); + auto conn = std::make_unique(std::move(sock), global_api_server); + conn->start(); + return {std::move(conn), read_fd}; +} + +// Test subclass to access protected configure_entity_() for benchmark setup. +class TestSensor : public sensor::Sensor { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } +}; + +// --- send_sensor_state: immediate send path --- +// Measures: send_message_smart_ → prepare buffer → dispatch_message_ → +// try_send_sensor_state → fill key/device_id + proto encode → frame write → +// TCP send. This is the per-client cost when batch_delay=0 and initial states +// have been sent. + +static void SendSensorState_Immediate(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + bench_enable_immediate_send(conn.get()); + // batch_delay must be 0 for should_send_immediately_ to return true + uint16_t saved_delay = global_api_server->get_batch_delay(); + global_api_server->set_batch_delay(0); + + TestSensor sensor; + sensor.configure("test_sensor"); + sensor.publish_state(23.5f); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->send_sensor_state(&sensor); + } + drain_socket(read_fd); + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + global_api_server->set_batch_delay(saved_delay); + ::close(read_fd); +} +BENCHMARK(SendSensorState_Immediate); + +// --- send_sensor_state: batch path (cold — first call allocates) --- +// Measures: send_message_smart_ → schedule_message_ → deferred batch add. +// Includes one-time vector allocation cost. + +static void SendSensorState_Batch_Cold(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + + TestSensor sensor; + sensor.configure("test_sensor"); + sensor.publish_state(23.5f); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->send_sensor_state(&sensor); + } + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(SendSensorState_Batch_Cold); + +// --- send_sensor_state: batch path (warm — buffer already allocated) --- +// Measures steady-state batch cost after the vector has been allocated +// and cleared at least once. This is the typical path during normal +// operation after the first batch has been processed. + +static void SendSensorState_Batch_Warm(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + + TestSensor sensor; + sensor.configure("test_sensor"); + sensor.publish_state(23.5f); + + // Warm up: send once to allocate, then clear to keep capacity + conn->send_sensor_state(&sensor); + bench_clear_batch(conn.get()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->send_sensor_state(&sensor); + } + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(SendSensorState_Batch_Warm); + +// --- process_batch_: single sensor state (encode + frame + write) --- +// Measures the deferred batch processing path: dispatch_message_ → +// try_send_sensor_state → fill + proto encode → send_buffer → frame write. +// This is the cost paid on the next loop() after batching. + +static void ProcessBatch_SingleSensor(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + + TestSensor sensor; + sensor.configure("test_sensor"); + sensor.publish_state(23.5f); + + // Warm up batch vector + conn->send_sensor_state(&sensor); + bench_process_batch(conn.get()); + drain_socket(read_fd); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->send_sensor_state(&sensor); + bench_process_batch(conn.get()); + } + drain_socket(read_fd); + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(ProcessBatch_SingleSensor); + +// --- process_batch_: 5 different sensors --- +// Measures batch processing with multiple items queued. +// This exercises the multi-message path in process_batch_. + +static void ProcessBatch_5Sensors(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + + TestSensor sensors[5]; + for (int i = 0; i < 5; i++) { + char name[20]; + snprintf(name, sizeof(name), "sensor_%d", i); + sensors[i].configure(name); + sensors[i].publish_state(23.5f + static_cast(i)); + } + + // Warm up batch vector + for (auto &s : sensors) + conn->send_sensor_state(&s); + bench_process_batch(conn.get()); + drain_socket(read_fd); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + for (auto &s : sensors) + conn->send_sensor_state(&s); + bench_process_batch(conn.get()); + } + drain_socket(read_fd); + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(ProcessBatch_5Sensors); + +} // namespace esphome::api::benchmarks + +#endif // USE_API_PLAINTEXT && USE_SENSOR From be56be5201f23d41e060f3cee458607f0bf97729 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 16:14:45 -1000 Subject: [PATCH 156/160] [core] Reduce runtime_stats measurement overhead (#15359) --- esphome/core/component.cpp | 7 ------- esphome/core/component.h | 9 ++++----- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 955596ce95..288c3f01a3 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -519,13 +519,6 @@ WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t block } } -#ifdef USE_RUNTIME_STATS -void WarnIfComponentBlockingGuard::record_runtime_stats_() { - uint32_t duration_us = micros() - this->started_us_; - this->component_->runtime_stats_.record_time(duration_us); -} -#endif - #ifdef USE_SETUP_PRIORITY_OVERRIDE void clear_setup_priority_overrides() { // Free the setup priority map completely diff --git a/esphome/core/component.h b/esphome/core/component.h index c5a331ee29..d09b42b936 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -630,17 +630,17 @@ class WarnIfComponentBlockingGuard { { } - // Finish the timing operation and return the current time + // Finish the timing operation and return the current time (millis) // Inlined: the fast path is just millis() + subtract + compare inline uint32_t HOT finish() { - uint32_t curr_time = millis(); - uint32_t blocking_time = curr_time - this->started_; #ifdef USE_RUNTIME_STATS - this->record_runtime_stats_(); + this->component_->runtime_stats_.record_time(micros() - this->started_us_); #endif + uint32_t curr_time = millis(); #ifndef USE_BENCHMARK // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; + uint32_t blocking_time = curr_time - this->started_; if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { warn_blocking(this->component_, blocking_time); } @@ -655,7 +655,6 @@ class WarnIfComponentBlockingGuard { Component *component_; #ifdef USE_RUNTIME_STATS uint32_t started_us_; - void record_runtime_stats_(); #endif private: From f36d78e09c866136111c260d03c99820ff71fcee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 16:15:00 -1000 Subject: [PATCH 157/160] [core] Force inline Component::get_component_log_str() (#15363) --- esphome/core/component.cpp | 3 --- esphome/core/component.h | 4 +++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 288c3f01a3..2b5aba2a7b 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -267,9 +267,6 @@ void Component::call() { break; } } -const LogString *Component::get_component_log_str() const { - return component_source_lookup(this->component_source_index_); -} bool Component::should_warn_of_blocking(uint32_t blocking_time) { // Convert centisecond threshold to milliseconds for comparison uint32_t threshold_ms = static_cast(this->warn_if_blocking_over_) * 10U; diff --git a/esphome/core/component.h b/esphome/core/component.h index d09b42b936..f091f9434c 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -323,7 +323,9 @@ class Component { * * Returns LOG_STR("") if source not set */ - const LogString *get_component_log_str() const; + inline const LogString *get_component_log_str() const ESPHOME_ALWAYS_INLINE { + return component_source_lookup(this->component_source_index_); + } bool should_warn_of_blocking(uint32_t blocking_time); From 08c7b3afbdf332ff65e1648047ba3ff90f7e2d2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 16:53:53 -1000 Subject: [PATCH 158/160] [esp32_ble_tracker] Reduce scan cycle log spam (#15365) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index f2d60be641..c7f2319d69 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -249,7 +249,7 @@ void ESP32BLETracker::start_scan_(bool first) { return; } this->set_scanner_state_(ScannerState::STARTING); - ESP_LOGD(TAG, "Starting scan, set scanner state to STARTING."); + ESP_LOGV(TAG, "Starting scan, set scanner state to STARTING."); if (!first) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT for (auto *listener : this->listeners_) @@ -855,7 +855,7 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { } void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { - ESP_LOGD(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); + ESP_LOGV(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); #ifdef USE_ESP32_BLE_DEVICE this->already_discovered_.clear(); #endif From 1436d034bf699531d81b2545804ebb996288d15f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 17:11:47 -1000 Subject: [PATCH 159/160] [api] Inline DeferredBatch::add_item to eliminate push_back call barrier (#15353) --- esphome/components/api/api_connection.cpp | 36 ++--------------------- esphome/components/api/api_connection.h | 33 +++++++++++++++------ 2 files changed, 27 insertions(+), 42 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 79df85ada3..aa64ced64c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -132,8 +132,6 @@ APIConnection::APIConnection(std::unique_ptr sock, APIServer *pa #endif } -uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } - void APIConnection::start() { this->last_traffic_ = App.get_loop_component_start_time(); @@ -2072,37 +2070,9 @@ void APIConnection::on_fatal_error() { this->flags_.remove = true; } -void __attribute__((flatten)) APIConnection::DeferredBatch::push_item(const BatchItem &item) { items.push_back(item); } - -void APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, - uint8_t aux_data_index) { - // Check if we already have a message of this type for this entity - // This provides deduplication per entity/message_type combination - // O(n) but optimized for RAM and not performance. - // Skip deduplication for events - they are edge-triggered, every occurrence matters -#ifdef USE_EVENT - if (message_type != EventResponse::MESSAGE_TYPE) -#endif - { - for (const auto &item : items) { - if (item.entity == entity && item.message_type == message_type) - return; // Already queued - } - } - // No existing item found (or event), add new one - this->push_item({entity, message_type, estimated_size, aux_data_index}); -} - -void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { - // Add high priority message and swap to front - // This avoids expensive vector::insert which shifts all elements - // Note: We only ever have one high-priority message at a time (ping OR disconnect) - // If we're disconnecting, pings are blocked, so this simple swap is sufficient - this->push_item({entity, message_type, estimated_size, AUX_DATA_UNUSED}); - if (items.size() > 1) { - // Swap the new high-priority item to the front - std::swap(items.front(), items.back()); - } +bool APIConnection::schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { + this->deferred_batch_.add_item_front(entity, message_type, estimated_size); + return this->schedule_batch_(); } bool APIConnection::send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4ce1335650..13d5273ecb 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -644,11 +644,28 @@ class APIConnection final : public APIServerConnectionBase { // Add item to the batch (with deduplication) void add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, - uint8_t aux_data_index = AUX_DATA_UNUSED); + uint8_t aux_data_index = AUX_DATA_UNUSED) { + // Dedup: O(n) scan but optimized for RAM over performance + // Skip deduplication for events - they are edge-triggered, every occurrence matters +#ifdef USE_EVENT + if (message_type != EventResponse::MESSAGE_TYPE) +#endif + { + for (const auto &item : this->items) { + if (item.entity == entity && item.message_type == message_type) + return; // Already queued + } + } + this->items.push_back({entity, message_type, estimated_size, aux_data_index}); + } // Add item to the front of the batch (for high priority messages like ping) - void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size); - // Single push_back site to avoid duplicate _M_realloc_insert instantiation - void push_item(const BatchItem &item); + void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { + // Swap to front avoids expensive vector::insert which shifts all elements + this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED}); + if (this->items.size() > 1) { + std::swap(this->items.front(), this->items.back()); + } + } // Clear all items void clear() { @@ -713,7 +730,7 @@ class APIConnection final : public APIServerConnectionBase { ActiveIterator active_iterator_{ActiveIterator::NONE}; // Total: 2 (flags) + 2 + 2 + 1 = 7 bytes, then 1 byte padding to next 4-byte boundary - uint32_t get_batch_delay_ms_() const; + uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } // Message will use 8 more bytes than the minimum size, and typical // MTU is 1500. Sometimes users will see as low as 1460 MTU. // If its IPv6 the header is 40 bytes, and if its IPv4 @@ -780,10 +797,8 @@ class APIConnection final : public APIServerConnectionBase { } // Helper function to schedule a high priority message at the front of the batch - bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { - this->deferred_batch_.add_item_front(entity, message_type, estimated_size); - return this->schedule_batch_(); - } + // Out-of-line: callers (on_shutdown, check_keepalive_) are cold paths + bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size); // Helper function to log client messages with name and peername void log_client_(int level, const LogString *message); From 3fbf0f0c019da645c9e68cb217789711e58aa6c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 17:13:09 -1000 Subject: [PATCH 160/160] [api] Simplify encode_to_buffer to single resize call (#15355) --- esphome/components/api/api_connection.cpp | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index aa64ced64c..0f456ecd0c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2021,24 +2021,23 @@ uint16_t APIConnection::encode_to_buffer(uint32_t calculated_size, MessageEncode auto &shared_buf = conn->parent_->get_shared_buffer_ref(); + size_t to_add; if (conn->flags_.batch_first_message) { // First message - buffer already prepared by caller, just clear flag conn->flags_.batch_first_message = false; + to_add = calculated_size; } else { // Batch message second or later - // Add padding for previous message footer + this message header - size_t current_size = shared_buf.size(); - shared_buf.reserve_and_resize(current_size + total_calculated_size, current_size + footer_size + header_padding); + // Reserve for full message, resize to include footer gap + header padding + payload + to_add = total_calculated_size; } - // Pre-resize buffer to include payload, then encode through raw pointer - size_t write_start = shared_buf.size(); - shared_buf.resize(write_start + calculated_size); - ProtoWriteBuffer buffer{&shared_buf, write_start}; + shared_buf.resize(shared_buf.size() + to_add); + ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; encode_fn(msg, buffer); // Return total size (header + payload + footer) - return static_cast(header_padding + calculated_size + footer_size); + return static_cast(total_calculated_size); } bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { const bool is_log_message = (message_type == SubscribeLogsResponse::MESSAGE_TYPE);